@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,146 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1792107368123,
9
+ "tag": "0000_nosy_demogoblin",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1792110236763,
16
+ "tag": "0001_worried_grey_gargoyle",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1792110369085,
23
+ "tag": "0002_tough_squirrel_girl",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1792110528735,
30
+ "tag": "0003_loose_maria_hill",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1792110685939,
37
+ "tag": "0004_long_cerise",
38
+ "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1792110971681,
44
+ "tag": "0005_review_result",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1792111308909,
51
+ "tag": "0006_melted_menace",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1792112657720,
58
+ "tag": "0007_powerful_bloodscream",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1792115524318,
65
+ "tag": "0008_panel_state",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1792623095914,
72
+ "tag": "0009_workable_tiger_shark",
73
+ "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1792623358681,
79
+ "tag": "0010_nasty_blue_marvel",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1792626772918,
86
+ "tag": "0011_steep_lila_cheney",
87
+ "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "6",
92
+ "when": 1792627173096,
93
+ "tag": "0012_talented_big_bertha",
94
+ "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "6",
99
+ "when": 1792627711767,
100
+ "tag": "0013_third_mulholland_black",
101
+ "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "6",
106
+ "when": 1792686570245,
107
+ "tag": "0014_perfect_marten_broadcloak",
108
+ "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "6",
113
+ "when": 1792694856099,
114
+ "tag": "0015_long_bloodaxe",
115
+ "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "6",
120
+ "when": 1792696928274,
121
+ "tag": "0016_neat_true_believers",
122
+ "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "6",
127
+ "when": 1793069239835,
128
+ "tag": "0017_previous_swarm",
129
+ "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "6",
134
+ "when": 1793159586953,
135
+ "tag": "0018_opposite_plazm",
136
+ "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "6",
141
+ "when": 1793159956678,
142
+ "tag": "0019_hesitant_triton",
143
+ "breakpoints": true
144
+ }
145
+ ]
146
+ }
@@ -0,0 +1,43 @@
1
+ import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
2
+ export const needsHumanKinds = ['review_limit', 'agent_needs_human', 'retries_exhausted', 'timeout', 'auth', 'workspace', 'pr_invalid', 'usage_limit'];
3
+ export const epicStates = ['queued', 'running', 'ready_for_test', 'needs_human', 'cancelled', 'merged', 'repo_removed'];
4
+ export const epics = sqliteTable('epics', {
5
+ // Autoincrement id is the stable queue order: rows are never reused or reordered.
6
+ id: integer().primaryKey({ autoIncrement: true }),
7
+ repo: text().notNull(),
8
+ issue: integer().notNull(),
9
+ status: text({ enum: epicStates }).notNull().default('queued'),
10
+ queuedAt: integer().notNull().default(0), // ms since epoch, when the poll registered it
11
+ startedAt: integer(), // ms since epoch, when the scheduler reserved it
12
+ // Effective repo config frozen at reservation: later config changes do not touch a running epic.
13
+ config: text({ mode: 'json' }).$type(),
14
+ cancelReason: text(),
15
+ // The running label is a reflection of SQLite: true while it has not reached GitHub yet.
16
+ labelPending: integer({ mode: 'boolean' }).notNull().default(false),
17
+ stage: text({ enum: ['ship', 'review', 'fix'] }), // null until the author is launched
18
+ stageStartedAt: integer(), // ms since epoch, when the epic entered its current stage
19
+ // Where the author's epic worktree really is (read back from `git worktree list` after ship); fix turns run there.
20
+ workspace: text(),
21
+ lastActivityAt: integer(), // ms since epoch of the agent's last output, written at most every 10 s
22
+ lastError: text(), // the last technical failure of a stage; cleared when a result is imported
23
+ round: integer(), // review round, from 1 once the final PR is validated
24
+ prNumber: integer(),
25
+ prHeadSha: text(),
26
+ pr: text({ mode: 'json' }).$type(), // what GitHub said about the PR the last time the poll asked: a cache, never read to decide anything
27
+ reviewedSha: text(), // head the reviewer approved, set when the epic becomes ready_for_test
28
+ verification: text({ mode: 'json' }).$type(), // what the reviewer reported about the checks
29
+ observationsPassAt: integer(), // ms since epoch, when the observations pass started (once per epic); null until then
30
+ needsHumanReason: text(),
31
+ needsHumanKind: text({ enum: needsHumanKinds }), // what kind of stop the reason is; set together with it
32
+ title: text(), // the issue's title and labels as the poll last read them
33
+ labels: text({ mode: 'json' }).$type(),
34
+ branch: text(), // the final PR's branch, set when ship is validated
35
+ // Set while `repo_removed`: where the epic was and why it stopped, so it goes back there when the repo returns.
36
+ removedFrom: text({ enum: epicStates }),
37
+ removedReason: text(),
38
+ finishedAt: integer(), // ms since epoch, when the epic left `running`
39
+ // The agent option each role runs on, set when the epic is reserved and replaced by a choice; null on epics from before options existed.
40
+ authorOption: text(),
41
+ reviewerOption: text(),
42
+ choiceDeadline: integer(), // ms since epoch: while set, the running epic waits for its choice and has launched nothing
43
+ }, (table) => [uniqueIndex('epics_repo_issue').on(table.repo, table.issue)]);
@@ -0,0 +1,91 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { defaultConfigPath } from '../../config/default-path.js';
3
+ export const commandNames = ['init', 'doctor', 'schema', 'start', 'status', 'watch', 'stop', 'pause', 'resume', 'check', 'restart', 'chat', 'chat-host'];
4
+ export const usage = () => `Usage: carrier --config <path> run the orchestrator
5
+ carrier start --config <path> run it in the background, without a terminal
6
+ carrier init [flags] guided setup: writes the config and checks it
7
+ carrier doctor --config <path> [--json] check the config and what this machine needs (--json: the checks as a JSON array on stdout, for the app)
8
+ carrier schema print the JSON Schema of the config
9
+ carrier status show once what the running Carrier is doing (exit 1: not running)
10
+ carrier watch the live panel of the running Carrier; q leaves it running (exit 1: not running)
11
+ carrier stop|pause|resume|check stop Carrier, pause or resume intake, look at GitHub now
12
+ carrier restart <owner/name>#<issue> [--author <option>] [--reviewer <option>] resume a stopped epic on the options you choose
13
+ carrier chat --workspace <folder> [--agent <name>] talk to an agent in a folder (starts the chat host if needed)
14
+ carrier chat --resume <id> go on with a closed chat session, after the end of its transcript
15
+ carrier chat --list [--archived] list chat sessions (the archived ones with --archived)
16
+ carrier chat --archive|--unarchive|--delete <id> archive, unarchive or delete a chat session
17
+ carrier chat --stop stop the chat host
18
+ carrier chat-host run the chat host in the foreground
19
+ carrier --version print the installed version
20
+
21
+ --config defaults to ${defaultConfigPath()} (where init writes it) when that file exists.
22
+
23
+ restart: with neither flag it asks for each role in a terminal and keeps the current options without one. --author and --reviewer name an option
24
+ of the config here, not the <harness>:<model> of init.
25
+
26
+ init flags (each one skips its question; --yes never asks):
27
+ --repo <owner/name> repeat for several repos
28
+ --identities <separate|shared> separate: a different identity per role (default); shared: your gh login for both
29
+ --author-profile <forge:<agent>|gh> --reviewer-profile <forge:<agent>|gh> --lead-reviewer-profile <forge:<agent>|gh>
30
+ --author <harness>:<model> --reviewer <harness>:<model> --lead-reviewer <harness>:<model>
31
+ --author-provider <lmstudio|ollama> --reviewer-provider <lmstudio|ollama> --lead-reviewer-provider <lmstudio|ollama>
32
+ --author-persona-name <text> --reviewer-persona-name <text> --lead-reviewer-persona-name <text>
33
+ --author-persona-prompt <text> --reviewer-persona-prompt <text> --lead-reviewer-persona-prompt <text>
34
+ --out <path> default is the --config default above
35
+ --yes no questions; overwrite the output file (without --author and --reviewer, one claude option runs both roles)`;
36
+ const options = {
37
+ config: { type: 'string' },
38
+ repo: { type: 'string', multiple: true },
39
+ identities: { type: 'string' },
40
+ 'author-profile': { type: 'string' },
41
+ 'reviewer-profile': { type: 'string' },
42
+ 'lead-reviewer-profile': { type: 'string' },
43
+ author: { type: 'string' },
44
+ reviewer: { type: 'string' },
45
+ 'lead-reviewer': { type: 'string' },
46
+ 'author-provider': { type: 'string' },
47
+ 'reviewer-provider': { type: 'string' },
48
+ 'lead-reviewer-provider': { type: 'string' },
49
+ 'author-persona-name': { type: 'string' },
50
+ 'author-persona-prompt': { type: 'string' },
51
+ 'reviewer-persona-name': { type: 'string' },
52
+ 'reviewer-persona-prompt': { type: 'string' },
53
+ 'lead-reviewer-persona-name': { type: 'string' },
54
+ 'lead-reviewer-persona-prompt': { type: 'string' },
55
+ out: { type: 'string' },
56
+ yes: { type: 'boolean' },
57
+ json: { type: 'boolean' },
58
+ list: { type: 'boolean' },
59
+ stop: { type: 'boolean' },
60
+ workspace: { type: 'string' },
61
+ agent: { type: 'string' },
62
+ resume: { type: 'string' },
63
+ archived: { type: 'boolean' },
64
+ archive: { type: 'string' },
65
+ unarchive: { type: 'string' },
66
+ delete: { type: 'string' },
67
+ help: { type: 'boolean', short: 'h' },
68
+ version: { type: 'boolean', short: 'v' },
69
+ };
70
+ const targetPattern = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)$/;
71
+ /** The command line as data: no process exit, no output. A bad flag or an unknown command is `ok: false` with what to say. */
72
+ export function parseCli(argv) {
73
+ let parsed;
74
+ try {
75
+ parsed = parseArgs({ args: argv, allowPositionals: true, options });
76
+ }
77
+ catch (error) {
78
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
79
+ }
80
+ const [command, ...extra] = parsed.positionals;
81
+ if (command === 'restart') {
82
+ const match = extra.length === 1 ? targetPattern.exec(extra[0]) : null;
83
+ const repo = match?.[1];
84
+ if (!match || repo.split('/').some((part) => part === '.' || part === '..') || !Number.isSafeInteger(Number(match[2])))
85
+ return { ok: false, message: 'restart takes one epic as owner/name#N, for example AshuLab/carrier#12' };
86
+ return { ok: true, command, flags: parsed.values, target: { repo: repo, issue: Number(match[2]) } };
87
+ }
88
+ if (extra.length > 0 || (command !== undefined && !commandNames.includes(command)))
89
+ return { ok: false, message: `Unknown command: ${parsed.positionals.join(' ')}` };
90
+ return { ok: true, command: command, flags: parsed.values };
91
+ }
@@ -0,0 +1,11 @@
1
+ /** A failure to report to the person at the terminal and exit with `code`: bad usage (2) or a problem they can fix (1). */
2
+ export class CliError extends Error {
3
+ code;
4
+ lines;
5
+ // No parameter properties: Node strips types at runtime and cannot run that syntax.
6
+ constructor(code, ...lines) {
7
+ super(lines.join('\n'));
8
+ this.code = code;
9
+ this.lines = lines;
10
+ }
11
+ }
@@ -0,0 +1,18 @@
1
+ import { createScopedLogger } from '@ashulab/universal-logger';
2
+ import { ChatConfigInvalid, HostBusy, startChatHost } from '../../../chat/host.js';
3
+ import { CliError } from '../cli-error.js';
4
+ /** `carrier chat-host`: the chat host in the foreground, from the Carrier folder alone, until a `stop` request, the idle exit or a signal. How a client's host runs, and how to debug it. */
5
+ export async function chatHostCommand() {
6
+ try {
7
+ await startChatHost();
8
+ }
9
+ catch (error) {
10
+ if (error instanceof HostBusy)
11
+ throw new CliError(1, error.message);
12
+ if (error instanceof ChatConfigInvalid)
13
+ throw new CliError(1, ...error.message.split('\n'));
14
+ createScopedLogger('cli').error('chat host failed to start', error); // logging is installed by now: the stack goes to the log file
15
+ throw new CliError(1, `the chat host failed to start: ${error instanceof Error ? error.message : String(error)}`);
16
+ }
17
+ process.stdout.write(`Chat host running (pid ${process.pid}).\n`);
18
+ }
@@ -0,0 +1,321 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { join, resolve } from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { chatConfigPathOf, chatLogsOf, chatSocketOf } from '../../../chat/config.js';
5
+ import { CliError } from '../cli-error.js';
6
+ import { connectControl, ControlUnreachable } from '../control-client.js';
7
+ import { startDetached } from '../detached.js';
8
+ import { socketGone } from './control.js';
9
+ /** A client on the chat host's socket; null when no host answers. */
10
+ async function connectHost(path) {
11
+ try {
12
+ return await connectControl(path);
13
+ }
14
+ catch (error) {
15
+ if (error instanceof ControlUnreachable === false)
16
+ throw error;
17
+ if (error.reason === 'not-running')
18
+ return null;
19
+ if (error.reason === 'incompatible')
20
+ throw new CliError(2, 'The running chat host speaks a protocol this command does not (it expects 1). Update one of them.');
21
+ throw new CliError(1, `The chat host does not answer on ${path}`);
22
+ }
23
+ }
24
+ export const POLL_MS = 200;
25
+ /** The chat host's client, started on demand. */
26
+ async function hostClient(socketPath, start) {
27
+ const client = await connectHost(socketPath);
28
+ if (client !== null)
29
+ return client;
30
+ await start({ label: 'The chat host', args: ['chat-host'], socketPath, logPath: join(chatLogsOf(), 'start.log') });
31
+ const started = await connectHost(socketPath);
32
+ if (started === null)
33
+ throw new CliError(1, 'The chat host stopped answering right after it started');
34
+ return started;
35
+ }
36
+ const HISTORY_EVENTS = 20;
37
+ /** How long ago, in the largest whole unit. */
38
+ const ageOf = (ms) => {
39
+ const minutes = Math.floor(Math.max(ms, 0) / 60_000);
40
+ if (minutes < 1)
41
+ return 'just now';
42
+ if (minutes < 60)
43
+ return `${minutes}m ago`;
44
+ if (minutes < 24 * 60)
45
+ return `${Math.floor(minutes / 60)}h ago`;
46
+ return `${Math.floor(minutes / (24 * 60))}d ago`;
47
+ };
48
+ const describeOption = (o) => `${o.label} (${o.id}): ${o.current}\n offers: ${o.values.map((v) => v.id).join(', ')}\n`;
49
+ /**
50
+ * Opens a conversation, or resumes a session, and runs it in plain lines: one line in, the answer out as it streams. `/quit` leaves and the
51
+ * session stays open. Ctrl+C (`sigint`) while a turn runs interrupts it and returns to the prompt; at the prompt it leaves.
52
+ */
53
+ async function converse(client, { start, input, pollMs, sigint }) {
54
+ const write = (text) => void process.stdout.write(text);
55
+ let opened;
56
+ try {
57
+ opened = (await client.request(start.cmd, start.cmd === 'open' ? { agent: start.agent, folder: start.folder } : { sessionId: start.sessionId }));
58
+ }
59
+ catch (error) {
60
+ throw new CliError(1, error instanceof Error ? error.message : String(error));
61
+ }
62
+ const sessionId = opened.session.id;
63
+ write(`Chat ${sessionId} with ${opened.session.agent} in ${opened.session.folder}. /options, /option <id> <value>, /quit leaves it open.\n`);
64
+ let cursor = 0;
65
+ let printed = 0; // characters of the message being written that are already on screen
66
+ let finished = false; // a turn_end has been shown
67
+ const show = (reply) => {
68
+ for (const event of reply.events) {
69
+ if (event.kind === 'assistant')
70
+ write(`${String(event.text).slice(printed)}\n`), (printed = 0);
71
+ else if (event.kind === 'tool_start')
72
+ write(` [${event.tool}${event.arg ? ` ${event.arg}` : ''}]\n`);
73
+ else if (event.kind === 'error')
74
+ write(`error: ${event.text}\n`);
75
+ else if (event.kind === 'turn_end') {
76
+ finished = true;
77
+ printed = 0;
78
+ if (event.outcome === 'interrupted')
79
+ write('\n[interrupted]\n');
80
+ }
81
+ }
82
+ if (reply.streaming.length > printed)
83
+ write(reply.streaming.slice(printed)), (printed = reply.streaming.length);
84
+ cursor = reply.cursor;
85
+ };
86
+ const poll = async () => (await client.request('events', { sessionId, after: cursor, limit: 200 }));
87
+ let waitingOn = false; // a resumed session that was waiting for an approval when the person left
88
+ if (start.cmd === 'resume') {
89
+ // the socket pages forward by cursor only, so the end of the transcript is found by reading to its end
90
+ let tail = [];
91
+ for (let page = await poll();; page = await poll()) {
92
+ tail = [...tail, ...page.events].slice(-HISTORY_EVENTS);
93
+ cursor = page.cursor;
94
+ waitingOn = page.approvals.length > 0;
95
+ if (page.events.length < 200)
96
+ break;
97
+ }
98
+ for (const event of tail) {
99
+ if (event.kind === 'user')
100
+ write(`> ${event.text}\n`);
101
+ else if (event.kind === 'assistant')
102
+ write(`${event.text}\n`);
103
+ else if (event.kind === 'tool_start')
104
+ write(` [${event.tool}${event.arg ? ` ${event.arg}` : ''}]\n`);
105
+ else if (event.kind === 'error')
106
+ write(`error: ${event.text}\n`);
107
+ }
108
+ }
109
+ const reader = createInterface({ input });
110
+ const lines = reader[Symbol.asyncIterator]();
111
+ // A line asked for and not used (the approval it was for was answered elsewhere) stays asked for, so the next read gets it.
112
+ let waiting;
113
+ const nextLine = () => (waiting ??= lines.next());
114
+ const used = () => void (waiting = undefined);
115
+ let turning = false;
116
+ let left = false;
117
+ const onSigint = () => {
118
+ if (turning === false)
119
+ return reader.close(); // at the prompt: leave, as /quit does
120
+ client.request('interrupt', { sessionId }).catch((error) => write(`${error instanceof Error ? error.message : String(error)}\n`));
121
+ };
122
+ sigint.on('SIGINT', onSigint);
123
+ /** Shows an approval with the provider's choices and sends the one the person picks; gives up when it is no longer pending (an interrupt, another client). */
124
+ const decide = async (approval) => {
125
+ write(`${approval.tool}${approval.arg ? ` ${approval.arg}` : ''} needs your approval:\n${approval.choices.map((choice, i) => ` ${i + 1}) ${choice.label}\n`).join('')}`);
126
+ const watch = { on: true, still: () => watch.on };
127
+ const gone = (async () => {
128
+ while (watch.on) {
129
+ await sleep(pollMs);
130
+ const reply = await poll();
131
+ if (watch.still() === false)
132
+ return;
133
+ show(reply);
134
+ if (reply.approvals.some((a) => a.approvalId === approval.approvalId) === false)
135
+ return;
136
+ }
137
+ })();
138
+ try {
139
+ for (;;) {
140
+ write('choose> ');
141
+ const got = await Promise.race([nextLine(), gone]);
142
+ if (got === undefined)
143
+ return write('\n');
144
+ used();
145
+ if (got.done)
146
+ return void (left = true); // the person left: the approval stays pending
147
+ const text = String(got.value).trim();
148
+ const choice = approval.choices[Number(text) - 1] ?? approval.choices.find((c) => c.id === text);
149
+ if (choice === undefined) {
150
+ write(`Type a number from 1 to ${approval.choices.length}\n`);
151
+ continue;
152
+ }
153
+ watch.on = false;
154
+ try {
155
+ await client.request('answer', { sessionId, approvalId: approval.approvalId, choiceId: choice.id });
156
+ }
157
+ catch (error) {
158
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
159
+ }
160
+ return;
161
+ }
162
+ }
163
+ finally {
164
+ watch.on = false;
165
+ }
166
+ };
167
+ const asked = new Set();
168
+ /** Follows the turn that runs until it ends, asking each approval it raises. */
169
+ const follow = async () => {
170
+ finished = false;
171
+ while (finished === false && left === false) {
172
+ const reply = await poll();
173
+ show(reply);
174
+ for (const approval of reply.approvals) {
175
+ if (finished || left)
176
+ break;
177
+ if (asked.has(approval.approvalId))
178
+ continue;
179
+ asked.add(approval.approvalId);
180
+ await decide(approval);
181
+ }
182
+ if (finished === false && left === false)
183
+ await sleep(pollMs);
184
+ }
185
+ };
186
+ try {
187
+ if (waitingOn) {
188
+ turning = true;
189
+ try {
190
+ await follow();
191
+ }
192
+ catch (error) {
193
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
194
+ }
195
+ finally {
196
+ turning = false;
197
+ }
198
+ }
199
+ while (left === false) {
200
+ write('> ');
201
+ const { value, done } = await nextLine();
202
+ used();
203
+ const text = done ? '/quit' : String(value).trim();
204
+ if (text === '/quit')
205
+ break;
206
+ if (text === '')
207
+ continue;
208
+ if (text === '/options' || text.startsWith('/option ')) {
209
+ try {
210
+ if (text === '/options') {
211
+ // after the cursor, so what is waiting to be shown is not consumed
212
+ const { options } = (await client.request('events', { sessionId, after: cursor, limit: 1 }));
213
+ write(options.map(describeOption).join('') || 'This agent lists no options.\n');
214
+ }
215
+ else {
216
+ const [id, value, ...extra] = text.split(/\s+/).slice(1);
217
+ if (id === undefined || value === undefined || extra.length > 0) {
218
+ write('Usage: /option <id> <value>\n');
219
+ continue;
220
+ }
221
+ const { options } = (await client.request('setOption', { sessionId, option: id, value }));
222
+ write(describeOption(options.find((o) => o.id === id)));
223
+ }
224
+ }
225
+ catch (error) {
226
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
227
+ }
228
+ continue;
229
+ }
230
+ turning = true;
231
+ try {
232
+ await client.request('send', { sessionId, text });
233
+ await follow();
234
+ }
235
+ catch (error) {
236
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
237
+ }
238
+ finally {
239
+ turning = false;
240
+ }
241
+ }
242
+ }
243
+ finally {
244
+ sigint.off('SIGINT', onSigint);
245
+ }
246
+ write('\n');
247
+ }
248
+ /**
249
+ * `carrier chat --workspace <folder> [--agent <name>]`, `--resume <id>`, `--list [--archived]`, `--archive|--unarchive|--delete <id>` and `--stop`. A host is started on demand by --list and
250
+ * by a chat (`start` is replaceable in tests). The host is the one in the Carrier folder (`CARRIER_HOME`, else `~/.carrier`). Returns the exit code.
251
+ */
252
+ export async function chatCommand(flags, { start = startDetached, input = process.stdin, pollMs = POLL_MS, now = Date.now, sigint = process } = {}) {
253
+ const socketPath = chatSocketOf();
254
+ if (flags.stop) {
255
+ const client = await connectHost(socketPath);
256
+ if (client === null) {
257
+ process.stdout.write('No chat host is running\n');
258
+ return 0;
259
+ }
260
+ await client.request('stop');
261
+ client.close();
262
+ if ((await socketGone(socketPath)) === false)
263
+ throw new CliError(1, `The chat host still answers on ${socketPath} after 15 seconds`);
264
+ process.stdout.write('Chat host stopped\n');
265
+ return 0;
266
+ }
267
+ if (flags.list) {
268
+ const client = await hostClient(socketPath, start);
269
+ const { sessions } = (await client.request('list', flags.archived ? { archived: true } : {}));
270
+ client.close();
271
+ if (sessions.length === 0)
272
+ process.stdout.write(flags.archived ? 'No archived chat sessions.\n' : 'No chat sessions.\n');
273
+ // a session whose agent is not running is closed, whatever state it was left in
274
+ for (const s of sessions)
275
+ process.stdout.write(`${s.id} ${s.agent} ${s.live ? s.state : 'closed'} ${ageOf(now() - s.lastActiveAt)} ${s.folder}\n`);
276
+ return 0;
277
+ }
278
+ const change = flags.archive !== undefined ? [flags.archive, 'archive', { archived: true }, 'archived']
279
+ : flags.unarchive !== undefined ? [flags.unarchive, 'archive', { archived: false }, 'unarchived']
280
+ : flags.delete !== undefined ? [flags.delete, 'delete', {}, 'deleted']
281
+ : undefined;
282
+ if (change !== undefined) {
283
+ const [sessionId, cmd, extra, done] = change;
284
+ const client = await hostClient(socketPath, start);
285
+ try {
286
+ await client.request(cmd, { sessionId, ...extra });
287
+ }
288
+ catch (error) {
289
+ throw new CliError(1, error instanceof Error ? error.message : String(error));
290
+ }
291
+ finally {
292
+ client.close();
293
+ }
294
+ process.stdout.write(`Session ${sessionId} ${done}\n`);
295
+ return 0;
296
+ }
297
+ if (flags.resume !== undefined) {
298
+ const client = await hostClient(socketPath, start);
299
+ try {
300
+ await converse(client, { start: { cmd: 'resume', sessionId: flags.resume }, input, pollMs, sigint });
301
+ }
302
+ finally {
303
+ client.close();
304
+ }
305
+ return 0;
306
+ }
307
+ if (flags.workspace !== undefined) {
308
+ const client = await hostClient(socketPath, start);
309
+ try {
310
+ const agent = flags.agent ?? (await client.request('agents'))[0]?.name;
311
+ if (agent === undefined)
312
+ throw new CliError(1, `chat.json lists no agents, so there is none to chat with; add one to the "agents" of ${chatConfigPathOf()}`);
313
+ await converse(client, { start: { cmd: 'open', agent, folder: resolve(flags.workspace) }, input, pollMs, sigint });
314
+ }
315
+ finally {
316
+ client.close();
317
+ }
318
+ return 0;
319
+ }
320
+ throw new CliError(2, 'Usage: carrier chat --workspace <folder> [--agent <name>] | --resume <id> | --list [--archived] | --archive|--unarchive|--delete <id> | --stop');
321
+ }