@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,367 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
4
|
+
import { endGroup, resolveAgentBinary } from './group.js';
|
|
5
|
+
import { ChatRefused } from './runner.js';
|
|
6
|
+
// The Claude adapter of the chat runner: the Agent SDK pointed at the user's own `claude` binary, which runs as the
|
|
7
|
+
// leader of its own process group (docs/research/chat-spike.md). The SDK is only the transport: everything below the
|
|
8
|
+
// `query` seam is Carrier's own parsing of the messages it yields, and a message it does not know gives no event.
|
|
9
|
+
const log = createScopedLogger('chat');
|
|
10
|
+
const PERMISSION_MODES = [
|
|
11
|
+
{ id: 'default', label: 'Ask before each change' },
|
|
12
|
+
{ id: 'acceptEdits', label: 'Accept edits' },
|
|
13
|
+
{ id: 'plan', label: 'Plan only' },
|
|
14
|
+
{ id: 'auto', label: 'Auto' },
|
|
15
|
+
{ id: 'dontAsk', label: "Don't ask" },
|
|
16
|
+
]; // `bypassPermissions` is left out: it needs a flag of its own and Carrier does not turn it on
|
|
17
|
+
const resolveClaude = (command, env) => resolveAgentBinary('claude', command, env);
|
|
18
|
+
const claudeLoggedIn = (binary, env) => new Promise((done) => {
|
|
19
|
+
execFile(binary, ['auth', 'status'], { env, timeout: 15_000 }, (_error, stdout) => {
|
|
20
|
+
try {
|
|
21
|
+
done(JSON.parse(stdout).loggedIn === true);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
done(false);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const ownGroup = (o) => spawn(o.command, o.args, { cwd: o.cwd, env: o.env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
29
|
+
export const realClaudeDeps = { query: sdkQuery, resolve: resolveClaude, loggedIn: claudeLoggedIn, spawn: ownGroup };
|
|
30
|
+
export function claudeChatRunner(deps = realClaudeDeps) {
|
|
31
|
+
return {
|
|
32
|
+
async open({ folder, resume, options = {}, command, env }) {
|
|
33
|
+
let binary;
|
|
34
|
+
try {
|
|
35
|
+
binary = deps.resolve(command, env);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new ChatRefused(`Claude is not installed: ${command === undefined ? 'there is no "claude" on the path' : `"${command}" cannot be run`}`);
|
|
39
|
+
}
|
|
40
|
+
if (!(await deps.loggedIn(binary, env)))
|
|
41
|
+
throw new ChatRefused('Claude is not logged in; run "claude" once to log in');
|
|
42
|
+
const conversation = new ClaudeConversation(options, resume);
|
|
43
|
+
try {
|
|
44
|
+
await conversation.start(deps, { folder, binary, env });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
log.error('claude could not be started', error);
|
|
48
|
+
await conversation.close();
|
|
49
|
+
throw new ChatRefused(resume === undefined ? 'Claude could not be started' : 'Claude could not resume that conversation');
|
|
50
|
+
}
|
|
51
|
+
return conversation;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
class ClaudeConversation {
|
|
56
|
+
nativeId;
|
|
57
|
+
listeners = [];
|
|
58
|
+
endListeners = [];
|
|
59
|
+
query;
|
|
60
|
+
models = [];
|
|
61
|
+
/** What the SDK lists to invoke (skills of the person, the project and plugins, and Claude Code's own commands). */
|
|
62
|
+
slash = [];
|
|
63
|
+
/** The ones whose use is the terminal's (`/exit`): a message box has no business listing them. */
|
|
64
|
+
terminalOnly = new Set();
|
|
65
|
+
mode;
|
|
66
|
+
model;
|
|
67
|
+
effort;
|
|
68
|
+
input = inputQueue();
|
|
69
|
+
pending = new Map();
|
|
70
|
+
tools = new Map();
|
|
71
|
+
turning = false;
|
|
72
|
+
interrupting = false;
|
|
73
|
+
/** Turns that Carrier started itself (an `/effort` change) and the person must not see. */
|
|
74
|
+
hidden = 0;
|
|
75
|
+
lastModel;
|
|
76
|
+
closing = false;
|
|
77
|
+
pid;
|
|
78
|
+
command;
|
|
79
|
+
chosen;
|
|
80
|
+
constructor(chosen, resume) {
|
|
81
|
+
this.chosen = chosen;
|
|
82
|
+
this.nativeId = resume;
|
|
83
|
+
this.mode = chosen.permissionMode ?? 'default';
|
|
84
|
+
this.model = chosen.model ?? 'default';
|
|
85
|
+
this.effort = chosen.effort ?? '';
|
|
86
|
+
}
|
|
87
|
+
async start(deps, { folder, binary, env }) {
|
|
88
|
+
const spawnOwn = deps.spawn && ((o) => {
|
|
89
|
+
const child = deps.spawn(o);
|
|
90
|
+
this.pid = child.pid;
|
|
91
|
+
this.command = o.command;
|
|
92
|
+
return child;
|
|
93
|
+
});
|
|
94
|
+
this.query = deps.query({
|
|
95
|
+
prompt: this.input.iterable,
|
|
96
|
+
options: {
|
|
97
|
+
cwd: folder,
|
|
98
|
+
env,
|
|
99
|
+
pathToClaudeCodeExecutable: binary,
|
|
100
|
+
includePartialMessages: true,
|
|
101
|
+
canUseTool: (tool, input, { toolUseID, suggestions = [], title }) => this.ask(toolUseID, tool, input, suggestions, title),
|
|
102
|
+
...(spawnOwn && { spawnClaudeCodeProcess: spawnOwn }),
|
|
103
|
+
...(this.chosen.model && { model: this.chosen.model }),
|
|
104
|
+
...(this.chosen.permissionMode && { permissionMode: this.chosen.permissionMode }),
|
|
105
|
+
...(this.chosen.effort && { effort: this.chosen.effort }),
|
|
106
|
+
...(this.nativeId && { resume: this.nativeId }),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const ready = await this.query.initializationResult();
|
|
110
|
+
this.models = ready.models;
|
|
111
|
+
this.slash = ready.commands ?? [];
|
|
112
|
+
void this.pump();
|
|
113
|
+
}
|
|
114
|
+
get process() {
|
|
115
|
+
return this.pid === undefined || this.command === undefined ? undefined : { pid: this.pid, pgid: this.pid, command: this.command }; // it leads its own group (`ownGroup`)
|
|
116
|
+
}
|
|
117
|
+
get options() {
|
|
118
|
+
const efforts = this.models.find((m) => m.value === this.model)?.supportedEffortLevels ?? this.models.find((m) => m.value === 'default')?.supportedEffortLevels ?? [];
|
|
119
|
+
return [
|
|
120
|
+
{ id: 'permissionMode', label: 'Permission mode', values: PERMISSION_MODES, current: this.mode },
|
|
121
|
+
{ id: 'model', label: 'Model', values: this.models.map((m) => ({ id: m.value, label: m.displayName, ...(m.description && { detail: m.description }), ...(m.resolvedModel && { version: m.resolvedModel }) })), current: this.model },
|
|
122
|
+
...(efforts.length > 0 ? [{ id: 'effort', label: 'Reasoning effort', values: efforts.map((id) => ({ id, label: id })), current: this.effort }] : []),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
get commands() {
|
|
126
|
+
return this.slash
|
|
127
|
+
.filter((c) => !this.terminalOnly.has(c.name))
|
|
128
|
+
.map((c) => ({ id: c.name, name: c.name, description: c.description, ...(c.argumentHint && { argumentHint: c.argumentHint }), kind: c.builtin ? 'command' : 'skill' }));
|
|
129
|
+
}
|
|
130
|
+
send(text, command) {
|
|
131
|
+
this.turning = true;
|
|
132
|
+
this.interrupting = false;
|
|
133
|
+
// Claude Code reads a slash at the start of a message as the command or skill that follows it
|
|
134
|
+
this.input.push(command === undefined ? text : `/${command.name}${text === '' ? '' : ` ${text}`}`);
|
|
135
|
+
}
|
|
136
|
+
answer(approvalId, choiceId) {
|
|
137
|
+
const ask = this.pending.get(approvalId);
|
|
138
|
+
if (ask === undefined)
|
|
139
|
+
throw new ChatRefused('That approval is not pending any more');
|
|
140
|
+
const suggestion = choiceId.startsWith('suggestion:') ? ask.suggestions[Number(choiceId.slice('suggestion:'.length))] : undefined;
|
|
141
|
+
if (choiceId === 'deny')
|
|
142
|
+
ask.resolve({ behavior: 'deny', message: 'The user declined this.' });
|
|
143
|
+
else if (choiceId === 'allow')
|
|
144
|
+
ask.resolve({ behavior: 'allow', updatedInput: ask.input });
|
|
145
|
+
else if (suggestion)
|
|
146
|
+
ask.resolve({ behavior: 'allow', updatedInput: ask.input, updatedPermissions: [suggestion] });
|
|
147
|
+
else
|
|
148
|
+
throw new ChatRefused('That is not one of the choices');
|
|
149
|
+
this.pending.delete(approvalId);
|
|
150
|
+
this.emit({ kind: 'approval_answered', approvalId, choiceId });
|
|
151
|
+
}
|
|
152
|
+
async interrupt() {
|
|
153
|
+
if (!this.turning)
|
|
154
|
+
return;
|
|
155
|
+
this.interrupting = true;
|
|
156
|
+
this.denyPending();
|
|
157
|
+
await this.query.interrupt();
|
|
158
|
+
// The turn ends at its `result`; wait for it, so the host never sees the next turn start before this one's end.
|
|
159
|
+
for (let waited = 0; this.turning && waited < 5_000; waited += 50)
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
161
|
+
}
|
|
162
|
+
async setOption(id, value) {
|
|
163
|
+
const option = this.options.find((o) => o.id === id);
|
|
164
|
+
if (option === undefined)
|
|
165
|
+
throw new ChatRefused(`There is no option "${id}"`);
|
|
166
|
+
if (!option.values.some((v) => v.id === value))
|
|
167
|
+
throw new ChatRefused(`"${value}" is not a value of ${id}`);
|
|
168
|
+
if (id === 'permissionMode') {
|
|
169
|
+
await this.query.setPermissionMode(value);
|
|
170
|
+
this.mode = value;
|
|
171
|
+
}
|
|
172
|
+
else if (id === 'model') {
|
|
173
|
+
await this.query.setModel(value === 'default' ? undefined : value);
|
|
174
|
+
this.model = value;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
if (this.turning)
|
|
178
|
+
throw new ChatRefused('The reasoning effort can only change between turns');
|
|
179
|
+
this.hidden++;
|
|
180
|
+
this.input.push(`/effort ${value}`);
|
|
181
|
+
this.effort = value;
|
|
182
|
+
}
|
|
183
|
+
this.emit({ kind: 'option', id, value });
|
|
184
|
+
}
|
|
185
|
+
async close() {
|
|
186
|
+
if (this.closing)
|
|
187
|
+
return;
|
|
188
|
+
this.closing = true;
|
|
189
|
+
this.denyPending();
|
|
190
|
+
this.input.end();
|
|
191
|
+
this.query?.close();
|
|
192
|
+
if (this.pid !== undefined)
|
|
193
|
+
await endGroup(this.pid);
|
|
194
|
+
}
|
|
195
|
+
onEvent(listener) {
|
|
196
|
+
this.listeners.push(listener);
|
|
197
|
+
}
|
|
198
|
+
onEnd(listener) {
|
|
199
|
+
this.endListeners.push(listener);
|
|
200
|
+
}
|
|
201
|
+
emit(...events) {
|
|
202
|
+
for (const event of events)
|
|
203
|
+
for (const listener of this.listeners)
|
|
204
|
+
listener(event);
|
|
205
|
+
}
|
|
206
|
+
denyPending() {
|
|
207
|
+
for (const ask of this.pending.values())
|
|
208
|
+
ask.resolve({ behavior: 'deny', message: 'The turn was interrupted.', interrupt: true });
|
|
209
|
+
this.pending.clear();
|
|
210
|
+
}
|
|
211
|
+
ask(approvalId, tool, input, suggestions, title) {
|
|
212
|
+
const choices = [
|
|
213
|
+
{ id: 'allow', label: 'Allow', allow: true },
|
|
214
|
+
{ id: 'deny', label: 'Deny', allow: false },
|
|
215
|
+
...suggestions.map((s, i) => ({ id: `suggestion:${i}`, label: labelOf(s), allow: true })),
|
|
216
|
+
];
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
this.pending.set(approvalId, { input, suggestions, resolve });
|
|
219
|
+
this.emit({ kind: 'approval', approvalId, tool, arg: argOf(input) ?? title, choices });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async pump() {
|
|
223
|
+
try {
|
|
224
|
+
for await (const message of this.query) {
|
|
225
|
+
try {
|
|
226
|
+
this.emit(...this.translate(message));
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
log.error('a claude message could not be handled', error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!this.closing)
|
|
235
|
+
log.error('the claude stream failed', error);
|
|
236
|
+
}
|
|
237
|
+
if (this.closing)
|
|
238
|
+
return;
|
|
239
|
+
// The process ended on its own: that is the end of the turn that was running.
|
|
240
|
+
this.emit({ kind: 'error', text: 'The Claude process ended' });
|
|
241
|
+
if (this.turning)
|
|
242
|
+
this.emit({ kind: 'turn_end', outcome: 'error', model: this.lastModel });
|
|
243
|
+
this.turning = false;
|
|
244
|
+
for (const listener of this.endListeners)
|
|
245
|
+
listener();
|
|
246
|
+
}
|
|
247
|
+
/** The events of one SDK message. Never throws on a shape it does not know: that message gives none. */
|
|
248
|
+
translate(message) {
|
|
249
|
+
const m = message;
|
|
250
|
+
if (typeof m.session_id === 'string')
|
|
251
|
+
this.nativeId = m.session_id;
|
|
252
|
+
if (m.type === 'result')
|
|
253
|
+
return this.turnEnd(m);
|
|
254
|
+
if (this.hidden > 0)
|
|
255
|
+
return [];
|
|
256
|
+
switch (m.type) {
|
|
257
|
+
case 'system':
|
|
258
|
+
if (m.subtype === 'init' && typeof m.permissionMode === 'string')
|
|
259
|
+
this.mode = m.permissionMode;
|
|
260
|
+
if (m.subtype === 'init' && Array.isArray(m.terminal_slash_commands))
|
|
261
|
+
this.terminalOnly = new Set(m.terminal_slash_commands);
|
|
262
|
+
if (m.subtype === 'status' && typeof m.permissionMode === 'string' && m.permissionMode !== this.mode) {
|
|
263
|
+
this.mode = m.permissionMode;
|
|
264
|
+
return [{ kind: 'option', id: 'permissionMode', value: this.mode }];
|
|
265
|
+
}
|
|
266
|
+
return [];
|
|
267
|
+
case 'stream_event': {
|
|
268
|
+
const delta = m.event?.type === 'content_block_delta' && m.event.delta?.type === 'text_delta' ? m.event.delta.text : undefined;
|
|
269
|
+
return typeof delta === 'string' && delta !== '' ? [{ kind: 'delta', text: delta }] : [];
|
|
270
|
+
}
|
|
271
|
+
case 'assistant': {
|
|
272
|
+
if (typeof m.message?.model === 'string')
|
|
273
|
+
this.lastModel = m.message.model;
|
|
274
|
+
const events = [];
|
|
275
|
+
for (const block of Array.isArray(m.message?.content) ? m.message.content : []) {
|
|
276
|
+
if (block?.type === 'text' && typeof block.text === 'string' && block.text !== '')
|
|
277
|
+
events.push({ kind: 'assistant', text: block.text });
|
|
278
|
+
if (block?.type === 'tool_use' && typeof block.id === 'string') {
|
|
279
|
+
this.tools.set(block.id, String(block.name));
|
|
280
|
+
events.push({ kind: 'tool_start', callId: block.id, tool: String(block.name), arg: argOf(block.input) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return events;
|
|
284
|
+
}
|
|
285
|
+
case 'user': {
|
|
286
|
+
const events = [];
|
|
287
|
+
for (const block of Array.isArray(m.message?.content) ? m.message.content : []) {
|
|
288
|
+
if (block?.type !== 'tool_result' || typeof block.tool_use_id !== 'string')
|
|
289
|
+
continue;
|
|
290
|
+
events.push({ kind: 'tool_end', callId: block.tool_use_id, tool: this.tools.get(block.tool_use_id) ?? '', output: textOf(block.content).split('\n'), status: block.is_error ? 'err' : 'ok' });
|
|
291
|
+
}
|
|
292
|
+
return events;
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
turnEnd(m) {
|
|
299
|
+
if (this.hidden > 0) {
|
|
300
|
+
this.hidden--;
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
const interrupted = this.interrupting;
|
|
304
|
+
this.turning = false;
|
|
305
|
+
this.interrupting = false;
|
|
306
|
+
const events = [];
|
|
307
|
+
if (m.is_error && !interrupted)
|
|
308
|
+
events.push({ kind: 'error', text: Array.isArray(m.errors) && m.errors.length > 0 ? m.errors.join('\n') : 'The turn failed' });
|
|
309
|
+
const usage = {};
|
|
310
|
+
for (const [key, value] of Object.entries((m.usage ?? {})))
|
|
311
|
+
if (typeof value === 'number')
|
|
312
|
+
usage[key] = value;
|
|
313
|
+
if (typeof m.total_cost_usd === 'number')
|
|
314
|
+
usage.total_cost_usd = m.total_cost_usd;
|
|
315
|
+
const model = this.lastModel ?? Object.keys((m.modelUsage ?? {}))[0];
|
|
316
|
+
events.push({ kind: 'turn_end', outcome: interrupted ? 'interrupted' : m.is_error ? 'error' : 'ok', model, usage });
|
|
317
|
+
return events;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Messages the person typed, as the stream the SDK reads; it ends when the conversation closes. */
|
|
321
|
+
function inputQueue() {
|
|
322
|
+
const waiting = [];
|
|
323
|
+
let wake;
|
|
324
|
+
let ended = false;
|
|
325
|
+
return {
|
|
326
|
+
push(text) {
|
|
327
|
+
waiting.push({ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null });
|
|
328
|
+
wake?.();
|
|
329
|
+
},
|
|
330
|
+
end() {
|
|
331
|
+
ended = true;
|
|
332
|
+
wake?.();
|
|
333
|
+
},
|
|
334
|
+
iterable: (async function* () {
|
|
335
|
+
while (true) {
|
|
336
|
+
const next = waiting.shift();
|
|
337
|
+
if (next !== undefined)
|
|
338
|
+
yield next;
|
|
339
|
+
else if (ended)
|
|
340
|
+
return;
|
|
341
|
+
else
|
|
342
|
+
await new Promise((resolve) => void (wake = resolve));
|
|
343
|
+
}
|
|
344
|
+
})(),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const textOf = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n') : '';
|
|
348
|
+
/** What a tool call is about, in one string: the command, the path, the pattern, else its input as JSON. */
|
|
349
|
+
const argOf = (input) => {
|
|
350
|
+
if (typeof input !== 'object' || input === null)
|
|
351
|
+
return undefined;
|
|
352
|
+
const fields = input;
|
|
353
|
+
const main = [fields.command, fields.file_path, fields.path, fields.pattern, fields.url].find((v) => typeof v === 'string');
|
|
354
|
+
return typeof main === 'string' ? main : JSON.stringify(input);
|
|
355
|
+
};
|
|
356
|
+
const labelOf = (update) => {
|
|
357
|
+
switch (update.type) {
|
|
358
|
+
case 'addRules':
|
|
359
|
+
return `Always allow ${update.rules.map((r) => r.ruleContent ?? r.toolName).join(', ')}`;
|
|
360
|
+
case 'addDirectories':
|
|
361
|
+
return `Allow ${update.directories.join(', ')}`;
|
|
362
|
+
case 'setMode':
|
|
363
|
+
return `Allow and switch to ${update.mode}`;
|
|
364
|
+
default:
|
|
365
|
+
return 'Allow and change the permissions';
|
|
366
|
+
}
|
|
367
|
+
};
|