@ashulab/carrier 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +265 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { endGroup, resolveAgentBinary } from './group.js';
|
|
5
|
+
import { ChatRefused } from './runner.js';
|
|
6
|
+
// The Codex adapter of the chat runner: the user's own `codex app-server`, JSON-RPC over stdio, one JSON object per
|
|
7
|
+
// line (docs/research/chat-spike.md). No npm dependency: the adapter spawns the binary and speaks the protocol itself.
|
|
8
|
+
//
|
|
9
|
+
// Written against codex-cli / app-server 0.153.4, which Codex marks as experimental: the protocol changes with the
|
|
10
|
+
// version. The messages below are the ones the spike recorded; a notification this adapter does not know gives no
|
|
11
|
+
// event, and a server request it does not know is answered with an error so that it never holds a turn. A version other
|
|
12
|
+
// than this one is logged when the server says hello.
|
|
13
|
+
export const CODEX_APP_SERVER_VERSION = '0.153.4';
|
|
14
|
+
const log = createScopedLogger('chat');
|
|
15
|
+
const APPROVAL_POLICIES = [
|
|
16
|
+
{ id: 'untrusted', label: 'Ask unless the command is known to be safe' },
|
|
17
|
+
{ id: 'on-request', label: 'The agent decides when to ask' },
|
|
18
|
+
{ id: 'never', label: 'Never ask' },
|
|
19
|
+
];
|
|
20
|
+
// `danger-full-access` is left out, as Claude's `bypassPermissions` is: Carrier does not turn it on.
|
|
21
|
+
const SANDBOXES = [
|
|
22
|
+
{ id: 'read-only', label: 'Read only' },
|
|
23
|
+
{ id: 'workspace-write', label: 'Write in the folder' },
|
|
24
|
+
];
|
|
25
|
+
/** A turn takes the sandbox as an object, a thread as a name. */
|
|
26
|
+
const SANDBOX_POLICIES = {
|
|
27
|
+
'read-only': { type: 'readOnly', networkAccess: false },
|
|
28
|
+
'workspace-write': { type: 'workspaceWrite', writableRoots: [], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false },
|
|
29
|
+
};
|
|
30
|
+
const codexLoggedIn = (binary, env) => new Promise((done) => {
|
|
31
|
+
execFile(binary, ['login', 'status'], { env, timeout: 15_000 }, (error) => done(error === null)); // what it prints is not read: it names the login
|
|
32
|
+
});
|
|
33
|
+
const ownGroup = (binary, { cwd, env }) => {
|
|
34
|
+
const child = spawn(binary, ['app-server'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
35
|
+
child.stderr.resume(); // drained and never read: it may say things about the login
|
|
36
|
+
child.stdin.on('error', () => { }); // a write after the process ended; `onExit` reports the end
|
|
37
|
+
const lines = createInterface({ input: child.stdout });
|
|
38
|
+
return {
|
|
39
|
+
pid: child.pid,
|
|
40
|
+
write: (line) => void child.stdin.write(`${line}\n`),
|
|
41
|
+
onLine: (listener) => void lines.on('line', listener),
|
|
42
|
+
onExit(listener) {
|
|
43
|
+
let called = false;
|
|
44
|
+
const once = () => called || ((called = true), listener());
|
|
45
|
+
child.once('error', once);
|
|
46
|
+
child.once('close', once);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export const realCodexDeps = { resolve: (command, env) => resolveAgentBinary('codex', command, env), loggedIn: codexLoggedIn, spawn: ownGroup };
|
|
51
|
+
export function codexChatRunner(deps = realCodexDeps) {
|
|
52
|
+
return {
|
|
53
|
+
async open({ folder, resume, options = {}, command, env }) {
|
|
54
|
+
let binary;
|
|
55
|
+
try {
|
|
56
|
+
binary = deps.resolve(command, env);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new ChatRefused(`Codex is not installed: ${command === undefined ? 'there is no "codex" on the path' : `"${command}" cannot be run`}`);
|
|
60
|
+
}
|
|
61
|
+
if (!(await deps.loggedIn(binary, env)))
|
|
62
|
+
throw new ChatRefused('Codex is not logged in; run "codex login" once to log in');
|
|
63
|
+
const conversation = new CodexConversation(options, binary);
|
|
64
|
+
try {
|
|
65
|
+
await conversation.start(deps, { folder, env, resume });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
log.error('codex could not be started', error);
|
|
69
|
+
await conversation.close();
|
|
70
|
+
throw new ChatRefused(resume === undefined ? 'Codex could not be started' : 'Codex could not resume that conversation');
|
|
71
|
+
}
|
|
72
|
+
return conversation;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
class CodexConversation {
|
|
77
|
+
nativeId;
|
|
78
|
+
listeners = [];
|
|
79
|
+
endListeners = [];
|
|
80
|
+
transport;
|
|
81
|
+
nextId = 1;
|
|
82
|
+
waiting = new Map();
|
|
83
|
+
/** Approvals asked and not answered: the server's request id, and the decision each choice answers with. */
|
|
84
|
+
pending = new Map();
|
|
85
|
+
/** The skills `skills/list` gave for the folder: what the person can invoke. */
|
|
86
|
+
skills = [];
|
|
87
|
+
models = [];
|
|
88
|
+
model;
|
|
89
|
+
effort;
|
|
90
|
+
approvalPolicy;
|
|
91
|
+
sandbox;
|
|
92
|
+
/** What changed since the last turn: Codex applies it from the next `turn/start`, and keeps it after. */
|
|
93
|
+
overrides = {};
|
|
94
|
+
turning = false;
|
|
95
|
+
turnId;
|
|
96
|
+
usage = {};
|
|
97
|
+
closing = false;
|
|
98
|
+
chosen;
|
|
99
|
+
binary;
|
|
100
|
+
constructor(chosen, binary) {
|
|
101
|
+
this.chosen = chosen;
|
|
102
|
+
this.binary = binary;
|
|
103
|
+
this.model = chosen.model ?? '';
|
|
104
|
+
this.effort = chosen.effort ?? '';
|
|
105
|
+
this.approvalPolicy = chosen.approvalPolicy ?? 'untrusted'; // every command that is not known to be safe asks, as Claude's default mode does
|
|
106
|
+
this.sandbox = chosen.sandbox ?? 'workspace-write';
|
|
107
|
+
}
|
|
108
|
+
async start(deps, { folder, env, resume }) {
|
|
109
|
+
this.transport = deps.spawn(this.binary, { cwd: folder, env });
|
|
110
|
+
this.transport.onLine((line) => this.receive(line));
|
|
111
|
+
this.transport.onExit(() => this.ended());
|
|
112
|
+
const hello = await this.request('initialize', { clientInfo: { name: 'carrier', title: 'Carrier', version: '0.0.0' } });
|
|
113
|
+
const version = /\/(\d+\.\d+\.\d+)/.exec(String(hello?.userAgent))?.[1];
|
|
114
|
+
if (version !== CODEX_APP_SERVER_VERSION)
|
|
115
|
+
log.warn('codex app-server is not the version this adapter was written against', { found: version, expected: CODEX_APP_SERVER_VERSION });
|
|
116
|
+
this.notify('initialized');
|
|
117
|
+
const params = { cwd: folder, approvalPolicy: this.approvalPolicy, sandbox: this.sandbox, ...(this.chosen.model && { model: this.chosen.model }) };
|
|
118
|
+
const started = await this.request(resume === undefined ? 'thread/start' : 'thread/resume', resume === undefined ? params : { threadId: resume, ...params });
|
|
119
|
+
this.nativeId = started.thread.id;
|
|
120
|
+
this.model = this.chosen.model ?? started.model ?? '';
|
|
121
|
+
this.effort = this.chosen.effort ?? started.reasoningEffort ?? '';
|
|
122
|
+
if (this.chosen.effort)
|
|
123
|
+
this.overrides.effort = this.chosen.effort; // a thread takes no effort: the first turn carries it
|
|
124
|
+
this.models = (await this.request('model/list', {})).data.filter((m) => !m.hidden);
|
|
125
|
+
// the skills of the folder; a provider that cannot say leaves the list empty and the conversation goes on
|
|
126
|
+
try {
|
|
127
|
+
const listed = await this.request('skills/list', { cwds: [folder] });
|
|
128
|
+
this.skills = (listed?.data ?? []).flatMap((entry) => (entry.skills ?? []).filter((skill) => skill.enabled !== false));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
log.warn('codex could not list its skills', { error: String(error) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
get process() {
|
|
135
|
+
return this.transport?.pid === undefined ? undefined : { pid: this.transport.pid, pgid: this.transport.pid, command: this.binary }; // it leads its own group (`ownGroup`)
|
|
136
|
+
}
|
|
137
|
+
get options() {
|
|
138
|
+
const efforts = this.effortsOf(this.model);
|
|
139
|
+
return [
|
|
140
|
+
{ id: 'approvalPolicy', label: 'Approvals', values: APPROVAL_POLICIES, current: this.approvalPolicy },
|
|
141
|
+
{ id: 'sandbox', label: 'Sandbox', values: SANDBOXES, current: this.sandbox },
|
|
142
|
+
{ id: 'model', label: 'Model', values: this.models.map((m) => ({ id: m.model, label: m.displayName, version: m.model })), current: this.model },
|
|
143
|
+
...(efforts.length > 0 ? [{ id: 'effort', label: 'Reasoning effort', values: efforts.map((id) => ({ id, label: id })), current: this.effort }] : []),
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
effortsOf = (model) => this.models.find((m) => m.model === model)?.supportedReasoningEfforts.map((e) => e.reasoningEffort) ?? [];
|
|
147
|
+
get commands() {
|
|
148
|
+
return this.skills.map((skill) => ({ id: skill.path, name: skill.name, description: skill.shortDescription ?? skill.description, kind: 'skill' }));
|
|
149
|
+
}
|
|
150
|
+
send(text, command) {
|
|
151
|
+
this.turning = true;
|
|
152
|
+
this.usage = {};
|
|
153
|
+
this.turnId = undefined;
|
|
154
|
+
const overrides = this.overrides;
|
|
155
|
+
this.overrides = {};
|
|
156
|
+
this.request('turn/start', { threadId: this.nativeId, input: this.inputOf(text, command), ...overrides }).then((started) => {
|
|
157
|
+
if (this.turning && this.turnId === undefined)
|
|
158
|
+
this.turnId = started?.turn?.id;
|
|
159
|
+
}, (error) => {
|
|
160
|
+
if (this.closing)
|
|
161
|
+
return;
|
|
162
|
+
log.error('codex did not start the turn', error);
|
|
163
|
+
this.emit({ kind: 'error', text: 'Codex did not start the turn' }, { kind: 'turn_end', outcome: 'error', model: this.model });
|
|
164
|
+
this.turning = false;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Codex takes a skill as an item of its own in the turn's input, before what the person wrote after it. */
|
|
168
|
+
inputOf(text, command) {
|
|
169
|
+
const written = text === '' ? [] : [{ type: 'text', text, text_elements: [] }];
|
|
170
|
+
return command === undefined ? written : [{ type: 'skill', name: command.name, path: command.id }, ...written];
|
|
171
|
+
}
|
|
172
|
+
answer(approvalId, choiceId) {
|
|
173
|
+
const ask = this.pending.get(approvalId);
|
|
174
|
+
if (ask === undefined)
|
|
175
|
+
throw new ChatRefused('That approval is not pending any more');
|
|
176
|
+
if (!(choiceId in ask.decisions))
|
|
177
|
+
throw new ChatRefused('That is not one of the choices');
|
|
178
|
+
this.pending.delete(approvalId);
|
|
179
|
+
this.emit({ kind: 'approval_answered', approvalId, choiceId });
|
|
180
|
+
this.respond(ask.requestId, { decision: ask.decisions[choiceId] });
|
|
181
|
+
}
|
|
182
|
+
async interrupt() {
|
|
183
|
+
if (!this.turning)
|
|
184
|
+
return;
|
|
185
|
+
this.denyPending();
|
|
186
|
+
for (let waited = 0; this.turnId === undefined && this.turning && waited < 2_000; waited += 50)
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
188
|
+
if (this.turnId !== undefined && this.turning)
|
|
189
|
+
await this.request('turn/interrupt', { threadId: this.nativeId, turnId: this.turnId });
|
|
190
|
+
// The turn ends at its `turn/completed`; wait for it, so the host never sees the next turn start before this one's end.
|
|
191
|
+
for (let waited = 0; this.turning && waited < 5_000; waited += 50)
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
193
|
+
}
|
|
194
|
+
async setOption(id, value) {
|
|
195
|
+
const option = this.options.find((o) => o.id === id);
|
|
196
|
+
if (option === undefined)
|
|
197
|
+
throw new ChatRefused(`There is no option "${id}"`);
|
|
198
|
+
if (!option.values.some((v) => v.id === value))
|
|
199
|
+
throw new ChatRefused(`"${value}" is not a value of ${id}`);
|
|
200
|
+
const events = [{ kind: 'option', id, value }];
|
|
201
|
+
if (id === 'model') {
|
|
202
|
+
this.model = value;
|
|
203
|
+
this.overrides.model = value;
|
|
204
|
+
// A level the new model does not list cannot stay.
|
|
205
|
+
const efforts = this.effortsOf(value);
|
|
206
|
+
if (efforts.length > 0 && !efforts.includes(this.effort)) {
|
|
207
|
+
this.effort = this.models.find((m) => m.model === value)?.defaultReasoningEffort ?? efforts[0];
|
|
208
|
+
this.overrides.effort = this.effort;
|
|
209
|
+
events.push({ kind: 'option', id: 'effort', value: this.effort });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (id === 'effort') {
|
|
213
|
+
this.effort = value;
|
|
214
|
+
this.overrides.effort = value;
|
|
215
|
+
}
|
|
216
|
+
else if (id === 'approvalPolicy') {
|
|
217
|
+
this.approvalPolicy = value;
|
|
218
|
+
this.overrides.approvalPolicy = value;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
this.sandbox = value;
|
|
222
|
+
this.overrides.sandboxPolicy = SANDBOX_POLICIES[value];
|
|
223
|
+
}
|
|
224
|
+
this.emit(...events);
|
|
225
|
+
}
|
|
226
|
+
async close() {
|
|
227
|
+
if (this.closing)
|
|
228
|
+
return;
|
|
229
|
+
this.closing = true;
|
|
230
|
+
this.denyPending();
|
|
231
|
+
this.rejectWaiting();
|
|
232
|
+
if (this.transport?.pid !== undefined)
|
|
233
|
+
await endGroup(this.transport.pid);
|
|
234
|
+
}
|
|
235
|
+
onEvent(listener) {
|
|
236
|
+
this.listeners.push(listener);
|
|
237
|
+
}
|
|
238
|
+
onEnd(listener) {
|
|
239
|
+
this.endListeners.push(listener);
|
|
240
|
+
}
|
|
241
|
+
emit(...events) {
|
|
242
|
+
for (const event of events)
|
|
243
|
+
for (const listener of this.listeners)
|
|
244
|
+
listener(event);
|
|
245
|
+
}
|
|
246
|
+
denyPending() {
|
|
247
|
+
for (const ask of this.pending.values())
|
|
248
|
+
this.respond(ask.requestId, { decision: 'cancel' });
|
|
249
|
+
this.pending.clear();
|
|
250
|
+
}
|
|
251
|
+
rejectWaiting() {
|
|
252
|
+
for (const call of this.waiting.values())
|
|
253
|
+
call.reject(new Error('The Codex process ended'));
|
|
254
|
+
this.waiting.clear();
|
|
255
|
+
}
|
|
256
|
+
ended() {
|
|
257
|
+
this.rejectWaiting();
|
|
258
|
+
if (this.closing)
|
|
259
|
+
return;
|
|
260
|
+
// The process ended on its own: that is the end of the turn that was running.
|
|
261
|
+
this.emit({ kind: 'error', text: 'The Codex process ended' });
|
|
262
|
+
if (this.turning)
|
|
263
|
+
this.emit({ kind: 'turn_end', outcome: 'error', model: this.model });
|
|
264
|
+
this.turning = false;
|
|
265
|
+
for (const listener of this.endListeners)
|
|
266
|
+
listener();
|
|
267
|
+
}
|
|
268
|
+
request(method, params) {
|
|
269
|
+
const id = this.nextId++;
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
this.waiting.set(id, { resolve, reject });
|
|
272
|
+
this.transport.write(JSON.stringify({ id, method, params }));
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
notify(method) {
|
|
276
|
+
this.transport.write(JSON.stringify({ method }));
|
|
277
|
+
}
|
|
278
|
+
respond(id, result) {
|
|
279
|
+
this.transport.write(JSON.stringify({ id, result }));
|
|
280
|
+
}
|
|
281
|
+
/** One line from the server. Never throws: a line it cannot read or does not know gives no event. */
|
|
282
|
+
receive(line) {
|
|
283
|
+
try {
|
|
284
|
+
const m = JSON.parse(line);
|
|
285
|
+
if (typeof m.method === 'string' && m.id !== undefined)
|
|
286
|
+
this.serverRequest(m.id, m.method, m.params);
|
|
287
|
+
else if (typeof m.method === 'string')
|
|
288
|
+
this.emit(...this.translate(m.method, m.params));
|
|
289
|
+
else if (m.id !== undefined) {
|
|
290
|
+
const call = this.waiting.get(m.id);
|
|
291
|
+
this.waiting.delete(m.id);
|
|
292
|
+
if (m.error === undefined)
|
|
293
|
+
call?.resolve(m.result);
|
|
294
|
+
else
|
|
295
|
+
call?.reject(new Error(`codex answered with an error: ${String(m.error?.message)}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
log.error('a codex message could not be handled', error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
serverRequest(requestId, method, params) {
|
|
303
|
+
if (method !== 'item/commandExecution/requestApproval') {
|
|
304
|
+
// A request left unanswered holds the turn: say the method is not there.
|
|
305
|
+
log.warn('codex asked for something this adapter does not handle', { method });
|
|
306
|
+
this.transport.write(JSON.stringify({ id: requestId, error: { code: -32601, message: `Method not supported: ${method}` } }));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const listed = Array.isArray(params?.availableDecisions) ? params.availableDecisions : [];
|
|
310
|
+
const amendment = listed.find((d) => typeof d === 'object' && d !== null && 'acceptWithExecpolicyAmendment' in d);
|
|
311
|
+
const decisions = { accept: 'accept', decline: 'decline' }; // `decline` is not listed and works
|
|
312
|
+
const choices = [
|
|
313
|
+
{ id: 'accept', label: 'Allow', allow: true },
|
|
314
|
+
{ id: 'decline', label: 'Deny', allow: false },
|
|
315
|
+
];
|
|
316
|
+
if (amendment !== undefined) {
|
|
317
|
+
decisions.amend = amendment;
|
|
318
|
+
choices.push({ id: 'amend', label: `Always allow ${Array.isArray(params.proposedExecpolicyAmendment) ? params.proposedExecpolicyAmendment.join(' ') : 'this command'}`, allow: true });
|
|
319
|
+
}
|
|
320
|
+
if (listed.includes('cancel')) {
|
|
321
|
+
decisions.cancel = 'cancel';
|
|
322
|
+
choices.push({ id: 'cancel', label: 'Deny and stop the turn', allow: false });
|
|
323
|
+
}
|
|
324
|
+
const approvalId = String(requestId);
|
|
325
|
+
this.pending.set(approvalId, { requestId, decisions });
|
|
326
|
+
this.emit({ kind: 'approval', approvalId, tool: 'command', arg: typeof params?.command === 'string' ? params.command : undefined, choices });
|
|
327
|
+
}
|
|
328
|
+
translate(method, params) {
|
|
329
|
+
switch (method) {
|
|
330
|
+
case 'turn/started':
|
|
331
|
+
if (typeof params?.turn?.id === 'string')
|
|
332
|
+
this.turnId = params.turn.id;
|
|
333
|
+
return [];
|
|
334
|
+
case 'serverRequest/resolved':
|
|
335
|
+
this.pending.delete(String(params?.requestId)); // answered elsewhere, or by the end of the turn
|
|
336
|
+
return [];
|
|
337
|
+
case 'item/agentMessage/delta':
|
|
338
|
+
return typeof params?.delta === 'string' && params.delta !== '' ? [{ kind: 'delta', text: params.delta }] : [];
|
|
339
|
+
case 'item/started': {
|
|
340
|
+
const item = params?.item;
|
|
341
|
+
return item?.type === 'commandExecution' && typeof item.id === 'string' ? [{ kind: 'tool_start', callId: item.id, tool: 'command', arg: typeof item.command === 'string' ? item.command : undefined }] : [];
|
|
342
|
+
}
|
|
343
|
+
case 'item/completed': {
|
|
344
|
+
const item = params?.item;
|
|
345
|
+
if (item?.type === 'agentMessage' && typeof item.text === 'string' && item.text !== '')
|
|
346
|
+
return [{ kind: 'assistant', text: item.text }];
|
|
347
|
+
if (item?.type !== 'commandExecution' || typeof item.id !== 'string')
|
|
348
|
+
return [];
|
|
349
|
+
return [{ kind: 'tool_end', callId: item.id, tool: 'command', ...(typeof item.aggregatedOutput === 'string' && { output: item.aggregatedOutput.split('\n') }), status: item.status === 'completed' && item.exitCode === 0 ? 'ok' : 'err' }];
|
|
350
|
+
}
|
|
351
|
+
case 'thread/tokenUsage/updated':
|
|
352
|
+
// One update per model call: the turn's usage is what its calls added up to.
|
|
353
|
+
for (const [key, value] of Object.entries((params?.tokenUsage?.last ?? {})))
|
|
354
|
+
if (typeof value === 'number')
|
|
355
|
+
this.usage[key] = (this.usage[key] ?? 0) + value;
|
|
356
|
+
return [];
|
|
357
|
+
case 'turn/completed':
|
|
358
|
+
return this.turnEnd(params?.turn);
|
|
359
|
+
default:
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
turnEnd(turn) {
|
|
364
|
+
const outcome = turn?.status === 'completed' ? 'ok' : turn?.status === 'interrupted' ? 'interrupted' : 'error';
|
|
365
|
+
const usage = this.usage;
|
|
366
|
+
this.turning = false;
|
|
367
|
+
this.turnId = undefined;
|
|
368
|
+
this.usage = {};
|
|
369
|
+
const events = [];
|
|
370
|
+
if (outcome === 'error')
|
|
371
|
+
events.push({ kind: 'error', text: typeof turn?.error?.message === 'string' ? turn.error.message : 'The turn failed' });
|
|
372
|
+
events.push({ kind: 'turn_end', outcome, model: this.model, usage });
|
|
373
|
+
return events;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { LogLevel } from '@ashulab/universal-logger';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { carrierHome, expandHome } from '../config/default-path.js';
|
|
6
|
+
/** An agent a chat can start with. Narrower than the engine's harnesses: Antigravity is not offered. */
|
|
7
|
+
export const chatAgent = z.strictObject({
|
|
8
|
+
harness: z.enum(['claude', 'codex']),
|
|
9
|
+
/** The binary, when it is not the one on the path. */
|
|
10
|
+
command: z.string().min(1).optional(),
|
|
11
|
+
model: z.string().min(1).optional(),
|
|
12
|
+
/** Default options of the provider, by the provider's own option id. */
|
|
13
|
+
options: z.record(z.string(), z.string()).optional(),
|
|
14
|
+
});
|
|
15
|
+
export const chatAgentName = /^[a-z][a-z0-9-]*$/;
|
|
16
|
+
/** Days of no activity before the session lifecycle acts (ADR 0019): a whole number, or `"never"` to turn that step off. */
|
|
17
|
+
const days = z.union([z.int().positive(), z.literal('never')]);
|
|
18
|
+
// The chat's own configuration level (ADR 0016): `<folder>/chat/chat.json`, every key optional, strict like the engine's config.
|
|
19
|
+
const chatConfigSchema = z.strictObject({
|
|
20
|
+
agents: z.record(z.string().regex(chatAgentName, 'a chat agent name is lowercase letters, digits and hyphens, starting with a letter'), chatAgent).optional(),
|
|
21
|
+
projectsFolder: z.string().min(1).transform(expandHome).refine(isAbsolute, 'must be an absolute path, or start with ~/').optional(),
|
|
22
|
+
githubCallTimeoutSeconds: z.int().positive().optional(),
|
|
23
|
+
logLevel: z.enum(LogLevel).optional(),
|
|
24
|
+
/** A session left alone this long is archived by the host's clock pass. */
|
|
25
|
+
archiveAfterDays: days.default(15),
|
|
26
|
+
/** An archived session left alone this long is deleted by the same pass, unless its worktree holds unsaved work; it must be greater than `archiveAfterDays` when both are numbers. */
|
|
27
|
+
deleteAfterDays: days.default(30),
|
|
28
|
+
}).refine((c) => typeof c.archiveAfterDays !== 'number' || typeof c.deleteAfterDays !== 'number' || c.deleteAfterDays > c.archiveAfterDays, {
|
|
29
|
+
path: ['deleteAfterDays'],
|
|
30
|
+
when: (payload) => payload.issues.length === 0, // a value already refused is not also reported against the other
|
|
31
|
+
error: 'must be greater than archiveAfterDays',
|
|
32
|
+
});
|
|
33
|
+
/** Where the chat keeps what it owns, inside the Carrier folder. */
|
|
34
|
+
export const chatDirOf = (folder = carrierHome()) => join(folder, 'chat');
|
|
35
|
+
export const chatStateOf = (folder = carrierHome()) => join(chatDirOf(folder), 'state');
|
|
36
|
+
export const chatLogsOf = (folder = carrierHome()) => join(chatDirOf(folder), 'logs');
|
|
37
|
+
export const chatSocketOf = (folder = carrierHome()) => join(chatStateOf(folder), 'chat.sock');
|
|
38
|
+
export const chatConfigPathOf = (folder = carrierHome()) => join(chatDirOf(folder), 'chat.json');
|
|
39
|
+
const defaultAgents = { claude: { harness: 'claude' }, codex: { harness: 'codex' } };
|
|
40
|
+
/** The chat's settings from the parsed contents of `chat.json` (`{}` when there is no file), with the defaults for what it leaves out. */
|
|
41
|
+
export function loadChatConfig(user, folder = carrierHome()) {
|
|
42
|
+
const parsed = chatConfigSchema.safeParse(user);
|
|
43
|
+
if (parsed.success === false)
|
|
44
|
+
return { ok: false, errors: parsed.error.issues.map((issue) => ({ path: issue.path.join('.') || '(root)', message: issue.message })) };
|
|
45
|
+
const { agents = defaultAgents, projectsFolder = join(chatDirOf(folder), 'projects'), githubCallTimeoutSeconds = 30, logLevel = LogLevel.INFO, archiveAfterDays, deleteAfterDays } = parsed.data;
|
|
46
|
+
return { ok: true, settings: { agents: Object.entries(agents).map(([name, agent]) => ({ name, ...agent })), projectsFolder, githubCallTimeoutSeconds, logLevel, archiveAfterDays, deleteAfterDays } };
|
|
47
|
+
}
|
|
48
|
+
/** `chat.json` read and validated; a file that is not there is the defaults, one that cannot be read or does not match is an error naming the key. */
|
|
49
|
+
export function readChatConfig(folder = carrierHome()) {
|
|
50
|
+
let user = {};
|
|
51
|
+
try {
|
|
52
|
+
user = JSON.parse(readFileSync(chatConfigPathOf(folder), 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT')
|
|
56
|
+
return { ok: false, errors: [{ path: '(file)', message: error instanceof SyntaxError ? `is not valid JSON: ${error.message}` : 'cannot be read' }] };
|
|
57
|
+
}
|
|
58
|
+
return loadChatConfig(user, folder);
|
|
59
|
+
}
|
|
60
|
+
/** The error a person sees when `chat.json` is wrong: the file and each key with what is wrong with it. */
|
|
61
|
+
export const chatConfigProblem = (folder, errors) => `Invalid chat config ${chatConfigPathOf(folder)}:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}`;
|
package/dist/chat/db.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
3
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
4
|
+
/** `chat.db`: the chat host's own file with its own migrations, so no other process migrates or writes the engine's database. */
|
|
5
|
+
export function openChatDatabase(file) {
|
|
6
|
+
const db = drizzle(new Database(file));
|
|
7
|
+
migrate(db, { migrationsFolder: `${import.meta.dirname}/migrations` });
|
|
8
|
+
return db;
|
|
9
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FakeClock } from '../orchestration/fake.js';
|
|
4
|
+
import { createProjects } from './projects.js';
|
|
5
|
+
import { ChatRefused } from './runner.js';
|
|
6
|
+
/**
|
|
7
|
+
* A conversation that plays what a test tells it: `reply` runs on each message sent, `emit` plays an event any time
|
|
8
|
+
* (a turn ends on cue, an approval is asked and stays pending until `answer` is called).
|
|
9
|
+
*/
|
|
10
|
+
export class FakeConversation {
|
|
11
|
+
options = [];
|
|
12
|
+
nativeId;
|
|
13
|
+
process;
|
|
14
|
+
sent = [];
|
|
15
|
+
answers = [];
|
|
16
|
+
closed = false;
|
|
17
|
+
/** Events to play when a message arrives. */
|
|
18
|
+
reply = () => { };
|
|
19
|
+
listeners = [];
|
|
20
|
+
endListeners = [];
|
|
21
|
+
/** What the fake lists for the person to invoke; the commands each message invoked go to `invoked`. */
|
|
22
|
+
commands = [];
|
|
23
|
+
invoked = [];
|
|
24
|
+
send(text, command) {
|
|
25
|
+
this.invoked.push(command?.id);
|
|
26
|
+
this.sent.push(text);
|
|
27
|
+
this.reply(text, this);
|
|
28
|
+
}
|
|
29
|
+
answer(approvalId, choiceId) {
|
|
30
|
+
this.answers.push({ approvalId, choiceId });
|
|
31
|
+
this.emit({ kind: 'approval_answered', approvalId, choiceId });
|
|
32
|
+
}
|
|
33
|
+
interrupts = 0;
|
|
34
|
+
/** Plays what the provider does on an interrupt; by default nothing, as an adapter that never says the turn ended. */
|
|
35
|
+
onInterrupt = () => { };
|
|
36
|
+
async interrupt() {
|
|
37
|
+
this.interrupts++;
|
|
38
|
+
this.onInterrupt(this);
|
|
39
|
+
}
|
|
40
|
+
async setOption(id, value) {
|
|
41
|
+
const option = this.options.find((o) => o.id === id);
|
|
42
|
+
if (option)
|
|
43
|
+
option.current = value;
|
|
44
|
+
this.emit({ kind: 'option', id, value });
|
|
45
|
+
}
|
|
46
|
+
async close() {
|
|
47
|
+
this.closed = true;
|
|
48
|
+
}
|
|
49
|
+
onEvent(listener) {
|
|
50
|
+
this.listeners.push(listener);
|
|
51
|
+
}
|
|
52
|
+
onEnd(listener) {
|
|
53
|
+
this.endListeners.push(listener);
|
|
54
|
+
}
|
|
55
|
+
/** Plays the agent's process ending on its own: the error (and the end of a running turn) as an adapter says them, then the end. */
|
|
56
|
+
die(...events) {
|
|
57
|
+
this.emit(...events);
|
|
58
|
+
for (const listener of this.endListeners)
|
|
59
|
+
listener();
|
|
60
|
+
}
|
|
61
|
+
emit(...events) {
|
|
62
|
+
for (const event of events)
|
|
63
|
+
for (const listener of this.listeners)
|
|
64
|
+
listener(event);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** A runner with no provider behind it: it hands out `FakeConversation`s and remembers what it was asked. */
|
|
68
|
+
export class FakeChatRunner {
|
|
69
|
+
opened = [];
|
|
70
|
+
conversations = [];
|
|
71
|
+
/** Set to make `open` refuse, as a provider that is not installed does. */
|
|
72
|
+
refuseWith;
|
|
73
|
+
/** Applied to each new conversation, to script its answers. */
|
|
74
|
+
setup = () => { };
|
|
75
|
+
async open(args) {
|
|
76
|
+
this.opened.push(args);
|
|
77
|
+
if (this.refuseWith !== undefined)
|
|
78
|
+
throw new ChatRefused(this.refuseWith);
|
|
79
|
+
const conversation = new FakeConversation();
|
|
80
|
+
this.setup(conversation);
|
|
81
|
+
this.conversations.push(conversation);
|
|
82
|
+
return conversation;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** A git with a table of folders and the repositories in them, which answers the commands the host sends, and plays scripted failures. */
|
|
86
|
+
export class FakeGit {
|
|
87
|
+
repos = new Map();
|
|
88
|
+
/** Every command run, in order. */
|
|
89
|
+
calls = [];
|
|
90
|
+
/** While set, a `clone` waits for it (or for its signal to abort) before it ends: a clone that is still running. */
|
|
91
|
+
clonePending;
|
|
92
|
+
/** While set, a `worktree add` waits for it (or for its signal to abort) before it makes anything: a worktree that is still being made. */
|
|
93
|
+
worktreePending;
|
|
94
|
+
/** Commands that fail instead of answering, by their arguments joined with a space. */
|
|
95
|
+
failures = new Map();
|
|
96
|
+
/** What a repository is when it has just been cloned, besides its origin: `head` and `branches` are `main` unless this says otherwise. */
|
|
97
|
+
cloned = {};
|
|
98
|
+
progress;
|
|
99
|
+
/** Plays a line of stderr of the clone that is running, as git writes it. */
|
|
100
|
+
emitProgress(...lines) {
|
|
101
|
+
for (const line of lines)
|
|
102
|
+
this.progress?.(line);
|
|
103
|
+
}
|
|
104
|
+
async run(folder, args, options) {
|
|
105
|
+
this.calls.push({ folder, args, timeoutMs: options?.timeoutMs, env: options?.env });
|
|
106
|
+
if (args[0] === 'clone') {
|
|
107
|
+
this.progress = options?.onProgress;
|
|
108
|
+
// clone -- <url> <dest>: git makes the folder first; a failure or an abort leaves it half made
|
|
109
|
+
const [url, dest] = args.slice(2);
|
|
110
|
+
mkdirSync(dest, { recursive: true });
|
|
111
|
+
const signal = options?.signal;
|
|
112
|
+
if (this.clonePending !== undefined)
|
|
113
|
+
await Promise.race([this.clonePending, new Promise((resolve) => signal?.addEventListener('abort', resolve, { once: true }))]);
|
|
114
|
+
if (signal?.aborted === true)
|
|
115
|
+
return { ok: false, code: null, stderr: 'killed' };
|
|
116
|
+
const failure = this.failures.get(args.join(' '));
|
|
117
|
+
if (failure !== undefined)
|
|
118
|
+
return failure;
|
|
119
|
+
mkdirSync(join(dest, '.git'));
|
|
120
|
+
this.repos.set(dest, { head: 'main', branches: ['main'], ...structuredClone(this.cloned), origin: url });
|
|
121
|
+
return { ok: true, out: '' };
|
|
122
|
+
}
|
|
123
|
+
if (args[0] === 'init') {
|
|
124
|
+
// init -b <branch>: a failure leaves a half-made .git, as git can
|
|
125
|
+
mkdirSync(join(folder, '.git'), { recursive: true });
|
|
126
|
+
const failure = this.failures.get(args.join(' '));
|
|
127
|
+
if (failure !== undefined)
|
|
128
|
+
return failure;
|
|
129
|
+
this.repos.set(folder, { head: args[2], branches: [] });
|
|
130
|
+
return { ok: true, out: '' };
|
|
131
|
+
}
|
|
132
|
+
const failure = this.failures.get(args.join(' '));
|
|
133
|
+
if (failure !== undefined)
|
|
134
|
+
return failure;
|
|
135
|
+
const repo = this.repos.get(folder);
|
|
136
|
+
if (repo === undefined)
|
|
137
|
+
return { ok: false, code: 128, stderr: 'fatal: not a git repository' };
|
|
138
|
+
const out = (...printed) => ({ ok: true, out: printed.map((line) => `${line}\n`).join('') });
|
|
139
|
+
const no = { ok: false, code: 1, stderr: '' };
|
|
140
|
+
const [verb, ...rest] = args[0] === '--no-optional-locks' ? args.slice(1) : args;
|
|
141
|
+
if (verb === 'rev-parse' && rest.includes('--verify'))
|
|
142
|
+
return [...repo.branches.map((name) => `refs/heads/${name}`), ...(repo.remoteBranches ?? []).map((name) => `refs/remotes/origin/${name}`)].includes(rest.at(-1)) ? out(rest.at(-1)) : no;
|
|
143
|
+
if (verb === 'worktree' && rest[0] === 'add') {
|
|
144
|
+
const signal = options?.signal;
|
|
145
|
+
if (this.worktreePending !== undefined)
|
|
146
|
+
await Promise.race([this.worktreePending, new Promise((resolve) => signal?.addEventListener('abort', resolve, { once: true }))]);
|
|
147
|
+
if (signal?.aborted === true)
|
|
148
|
+
return { ok: false, code: null, stderr: 'killed' };
|
|
149
|
+
// worktree add --no-track -b <branch> <path> <start>: the branch and the folder appear, as git makes them
|
|
150
|
+
const [branch, path, start] = rest.slice(3);
|
|
151
|
+
const known = [...repo.branches.map((name) => `refs/heads/${name}`), ...(repo.remoteBranches ?? []).map((name) => `refs/remotes/origin/${name}`)];
|
|
152
|
+
if (repo.branches.includes(branch) || known.includes(start) === false)
|
|
153
|
+
return { ok: false, code: 128, stderr: 'fatal: invalid reference' };
|
|
154
|
+
mkdirSync(path, { recursive: true });
|
|
155
|
+
repo.branches.push(branch);
|
|
156
|
+
(repo.worktrees ??= []).push({ path: path, branch: branch });
|
|
157
|
+
return out();
|
|
158
|
+
}
|
|
159
|
+
if (verb === 'worktree' && rest[0] === 'remove') {
|
|
160
|
+
rmSync(rest.at(-1), { recursive: true, force: true });
|
|
161
|
+
repo.worktrees = (repo.worktrees ?? []).filter((worktree) => worktree.path !== rest.at(-1));
|
|
162
|
+
return out();
|
|
163
|
+
}
|
|
164
|
+
if (verb === 'worktree' && rest[0] === 'prune')
|
|
165
|
+
return out();
|
|
166
|
+
if (verb === 'branch' && rest[0] === '-D') {
|
|
167
|
+
if (repo.branches.includes(rest[1]) === false)
|
|
168
|
+
return { ok: false, code: 1, stderr: 'error: branch not found' };
|
|
169
|
+
repo.branches = repo.branches.filter((name) => name !== rest[1]);
|
|
170
|
+
return out();
|
|
171
|
+
}
|
|
172
|
+
if (verb === 'rev-parse')
|
|
173
|
+
return out(String(repo.bare === true), repo.linked ? '/elsewhere/.git/worktrees/w' : '.git', repo.linked ? '/elsewhere/.git' : '.git', repo.fetchHead ?? '.git/FETCH_HEAD');
|
|
174
|
+
if (verb === 'config')
|
|
175
|
+
return repo.origin === undefined ? no : out(repo.origin);
|
|
176
|
+
if (verb === 'symbolic-ref' && rest.includes('refs/remotes/origin/HEAD'))
|
|
177
|
+
return repo.originHead === undefined ? no : out(`refs/remotes/origin/${repo.originHead}`);
|
|
178
|
+
if (verb === 'symbolic-ref')
|
|
179
|
+
return repo.head === null ? no : out(repo.head);
|
|
180
|
+
if (verb === 'for-each-ref')
|
|
181
|
+
return out(...repo.branches.map((name) => `refs/heads/${name}`), ...(repo.remoteBranches ?? []).map((name) => `refs/remotes/origin/${name}`), ...(repo.originHead === undefined ? [] : ['refs/remotes/origin/HEAD']));
|
|
182
|
+
if (verb === 'rev-list')
|
|
183
|
+
return out(String(repo.unpushed?.[rest[1].replace('refs/heads/', '')] ?? 0));
|
|
184
|
+
if (verb === 'switch') {
|
|
185
|
+
// switch <branch>, or switch --track origin/<branch>, which makes the local branch
|
|
186
|
+
const target = rest.at(-1);
|
|
187
|
+
const local = target.replace(/^origin\//, '');
|
|
188
|
+
if (rest[0] === '--track')
|
|
189
|
+
repo.branches.push(local);
|
|
190
|
+
else if (repo.branches.includes(target) === false)
|
|
191
|
+
return { ok: false, code: 128, stderr: 'fatal: invalid reference' };
|
|
192
|
+
repo.head = local;
|
|
193
|
+
return out();
|
|
194
|
+
}
|
|
195
|
+
if (verb === 'fetch')
|
|
196
|
+
return out();
|
|
197
|
+
if (verb === 'status')
|
|
198
|
+
return out(...(repo.status ?? []));
|
|
199
|
+
return { ok: false, code: 129, stderr: `the fake git does not know ${verb}` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** What brings repositories onto the Mac, over a fake git and a fake `gh`: the token is `ghp_fake`, GitHub lists nothing and knows `main` and `feat/x` for any repository. */
|
|
203
|
+
export const fakeProjects = (git, clock = new FakeClock(), github = {}) => createProjects({ git, clock, token: async () => 'ghp_fake', github: { repos: async () => ({ repos: [], hasMore: false }), login: async () => 'me', branches: async () => ({ default: 'main', branches: ['main', 'feat/x'], hasMore: false }), ...github } });
|