@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,766 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { and, asc, desc, eq, gt, isNotNull } from 'drizzle-orm';
|
|
5
|
+
import { createScopedLogger } from '@ashulab/universal-logger';
|
|
6
|
+
import { carrierHome } from '../config/default-path.js';
|
|
7
|
+
import { resolveBinary } from '../harnesses/common.js';
|
|
8
|
+
import { checkFolder } from './folder.js';
|
|
9
|
+
import { createHealth, realProbes } from './health.js';
|
|
10
|
+
import { ChatRefused } from './runner.js';
|
|
11
|
+
import { chatEvents, chatTurns, sessions } from './schema.js';
|
|
12
|
+
import { boundEvent, chatCaps, titleOf } from './transcript.js';
|
|
13
|
+
import { checkName, suggestName } from './names.js';
|
|
14
|
+
import { createProject, projectFolder } from './projects.js';
|
|
15
|
+
import { createWorktree, deleteWorktree, previewWorktree, removeWorktree } from './worktree.js';
|
|
16
|
+
import { checkout, fetchFolder, inspectFolder } from './workspace.js';
|
|
17
|
+
const log = createScopedLogger('chat');
|
|
18
|
+
export const IDLE_CLOSE_MS = 60_000;
|
|
19
|
+
const DAY = 86_400_000;
|
|
20
|
+
const idleFor = (row, live, now, days) => days !== 'never' && row.state === 'idle' && !live && now - row.lastActiveAt >= days * DAY;
|
|
21
|
+
/** Refuses, naming the option, one the provider does not list or a value it does not offer for it. */
|
|
22
|
+
function checkOption(options, id, value) {
|
|
23
|
+
const option = options.find((candidate) => candidate.id === id);
|
|
24
|
+
if (option === undefined)
|
|
25
|
+
throw new ChatRefused(`This agent has no option "${id}"; it offers: ${options.map((o) => o.id).join(', ') || 'none'}`);
|
|
26
|
+
if (option.values.some((offered) => offered.id === value) === false)
|
|
27
|
+
throw new ChatRefused(`"${value}" is not a value of the option "${id}"; it offers: ${option.values.map((v) => v.id).join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
export function binaryFound(command) {
|
|
30
|
+
try {
|
|
31
|
+
if (command.includes('/'))
|
|
32
|
+
accessSync(command, constants.X_OK);
|
|
33
|
+
else
|
|
34
|
+
resolveBinary(command);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The sessions of the chat host: what the socket's requests do, over `chat.db` and the runners. */
|
|
42
|
+
export function createSessions({ db, agents, clock, runners, env, git, projects, binaryFound, probes = realProbes, ages, onLiveChange }) {
|
|
43
|
+
const health = createHealth({ agents, clock, binaryFound, probes });
|
|
44
|
+
const lives = new Map();
|
|
45
|
+
/** Set by closeAll, the host's shutdown: a clock pass in flight leaves the sessions it has not reached alone. */
|
|
46
|
+
let closed = false;
|
|
47
|
+
// Sessions being deleted: no agent may open in their folder while it is removed.
|
|
48
|
+
const deleting = new Set();
|
|
49
|
+
let clients = 0;
|
|
50
|
+
const row = (sessionId) => {
|
|
51
|
+
const found = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
|
|
52
|
+
if (found === undefined)
|
|
53
|
+
throw new ChatRefused('There is no such session');
|
|
54
|
+
return found;
|
|
55
|
+
};
|
|
56
|
+
const summary = ({ keptAt: _keptAt, options: _options, pid: _pid, pgid: _pgid, command: _command, plan, workspaceKind, worktreeBranch, worktreeFrom, worktreeBase, worktreePath, ...rest }) => ({
|
|
57
|
+
...rest,
|
|
58
|
+
preparation: rest.state === 'preparing' ? 'running' : plan === null ? null : 'failed',
|
|
59
|
+
workspace: workspaceKind === 'worktree' ? { kind: 'worktree', branch: worktreeBranch, from: worktreeFrom, base: worktreeBase, path: worktreePath } : { kind: 'folder' },
|
|
60
|
+
});
|
|
61
|
+
// Worktrees are made one at a time: what a request checked (a free name) is still true when git adds.
|
|
62
|
+
let making = Promise.resolve();
|
|
63
|
+
const exclusive = (work) => {
|
|
64
|
+
const run = making.then(work, work);
|
|
65
|
+
making = run.catch(() => { });
|
|
66
|
+
return run;
|
|
67
|
+
};
|
|
68
|
+
// An idle session with no client attached is closed after a minute; working or waiting ones are kept.
|
|
69
|
+
const armIdle = (sessionId, live) => {
|
|
70
|
+
live.cancelIdle?.();
|
|
71
|
+
live.cancelIdle = live.state === 'idle' && clients === 0 ? clock.after(IDLE_CLOSE_MS, async () => void (await close({ sessionId }))) : undefined;
|
|
72
|
+
};
|
|
73
|
+
const touch = (sessionId, live, state = live.state) => {
|
|
74
|
+
live.state = state;
|
|
75
|
+
db.update(sessions).set({ state, lastActiveAt: clock.now(), ...(live.conversation.nativeId !== undefined && { nativeId: live.conversation.nativeId }) }).where(eq(sessions.id, sessionId)).run();
|
|
76
|
+
armIdle(sessionId, live);
|
|
77
|
+
};
|
|
78
|
+
const store = (sessionId, event) => {
|
|
79
|
+
const data = boundEvent(event);
|
|
80
|
+
if (data !== undefined)
|
|
81
|
+
db.insert(chatEvents).values({ sessionId, at: clock.now(), kind: event.kind, data }).run();
|
|
82
|
+
};
|
|
83
|
+
// Sessions whose steps run in the background and are not live yet: the host is not idle while one does. `abort` stops the running step, `done` ends when the steps stopped.
|
|
84
|
+
const preparing = new Map();
|
|
85
|
+
// The latest line of git's progress for the clone of a session that is cloning: shown by `events`, never stored.
|
|
86
|
+
const progress = new Map();
|
|
87
|
+
const stepEvent = (sessionId, step, status, detail) => store(sessionId, { kind: 'step', step, status, ...(detail !== undefined && { detail }) });
|
|
88
|
+
/** How the last event of a step left it, undefined when the step never ran. */
|
|
89
|
+
const lastStatus = (sessionId, step) => {
|
|
90
|
+
const events = db.select().from(chatEvents).where(and(eq(chatEvents.sessionId, sessionId), eq(chatEvents.kind, 'step'))).orderBy(desc(chatEvents.id)).all();
|
|
91
|
+
return events.find((event) => event.data.step === step)?.data.status;
|
|
92
|
+
};
|
|
93
|
+
/** Stops a preparation at a step: the session is idle, the plan stays and the event says why. */
|
|
94
|
+
const failStep = (sessionId, step, detail) => {
|
|
95
|
+
stepEvent(sessionId, step, 'failed', detail);
|
|
96
|
+
db.update(sessions).set({ state: 'idle', lastActiveAt: clock.now() }).where(eq(sessions.id, sessionId)).run();
|
|
97
|
+
};
|
|
98
|
+
// A host that stopped mid-preparation left sessions whose last step is still running: it comes back failed as interrupted.
|
|
99
|
+
for (const { id: sessionId } of db.select({ id: sessions.id }).from(sessions).where(isNotNull(sessions.plan)).all()) {
|
|
100
|
+
const last = db.select().from(chatEvents).where(and(eq(chatEvents.sessionId, sessionId), eq(chatEvents.kind, 'step'))).orderBy(desc(chatEvents.id)).limit(1).get();
|
|
101
|
+
if (last?.data.status === 'running')
|
|
102
|
+
stepEvent(sessionId, last.data.step, 'failed', 'Carrier stopped while this step was running');
|
|
103
|
+
db.update(sessions).set({ state: 'idle' }).where(and(eq(sessions.id, sessionId), eq(sessions.state, 'preparing'))).run();
|
|
104
|
+
}
|
|
105
|
+
const onEvent = (sessionId, live, event) => {
|
|
106
|
+
// The first thing the agent does after the first message ends the `message` step: an answer, or an error that fails it.
|
|
107
|
+
if (live.awaitingAnswer === true && event.kind !== 'option') {
|
|
108
|
+
live.awaitingAnswer = false;
|
|
109
|
+
if (event.kind === 'error' || (event.kind === 'turn_end' && event.outcome === 'error')) {
|
|
110
|
+
store(sessionId, event);
|
|
111
|
+
failStep(sessionId, 'message', 'The agent ended with an error before it answered');
|
|
112
|
+
live.turn = undefined;
|
|
113
|
+
live.state = 'idle';
|
|
114
|
+
close({ sessionId }).catch((error) => log.error('closing an agent that failed its first answer failed', error));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
stepEvent(sessionId, 'message', 'done');
|
|
118
|
+
db.update(sessions).set({ plan: null }).where(eq(sessions.id, sessionId)).run();
|
|
119
|
+
touch(sessionId, live, 'working');
|
|
120
|
+
}
|
|
121
|
+
if (event.kind === 'delta')
|
|
122
|
+
return void (live.streaming += event.text);
|
|
123
|
+
if (event.kind === 'turn_end' && live.turn === undefined)
|
|
124
|
+
return; // a turn already recorded (an interrupt, then the adapter's own end)
|
|
125
|
+
if (event.kind === 'assistant' || event.kind === 'turn_end')
|
|
126
|
+
live.streaming = '';
|
|
127
|
+
store(sessionId, event);
|
|
128
|
+
if (event.kind === 'approval')
|
|
129
|
+
live.approvals.set(event.approvalId, boundEvent(event));
|
|
130
|
+
if (event.kind === 'approval_answered')
|
|
131
|
+
live.approvals.delete(event.approvalId);
|
|
132
|
+
if (event.kind === 'turn_end')
|
|
133
|
+
live.approvals.clear(); // an interrupted turn drops what it was waiting for
|
|
134
|
+
if (event.kind === 'option') {
|
|
135
|
+
const { options } = row(sessionId);
|
|
136
|
+
db.update(sessions).set({ options: { ...options, [event.id]: event.value } }).where(eq(sessions.id, sessionId)).run();
|
|
137
|
+
}
|
|
138
|
+
if (event.kind === 'tool_start' && live.turn)
|
|
139
|
+
live.turn.toolCalls++;
|
|
140
|
+
if (event.kind === 'turn_end') {
|
|
141
|
+
const startedAt = live.turn?.startedAt ?? clock.now();
|
|
142
|
+
db.insert(chatTurns).values({ sessionId, startedAt, model: event.model ?? null, outcome: event.outcome, durationMs: clock.now() - startedAt, toolCalls: live.turn?.toolCalls ?? 0, usage: event.usage ?? null }).run();
|
|
143
|
+
live.turn = undefined;
|
|
144
|
+
}
|
|
145
|
+
touch(sessionId, live, event.kind === 'approval' ? 'waiting_approval' : event.kind === 'turn_end' ? 'idle' : event.kind === 'approval_answered' ? 'working' : live.state);
|
|
146
|
+
};
|
|
147
|
+
async function close({ sessionId }) {
|
|
148
|
+
row(sessionId);
|
|
149
|
+
const live = lives.get(sessionId);
|
|
150
|
+
if (live === undefined)
|
|
151
|
+
return {};
|
|
152
|
+
lives.delete(sessionId);
|
|
153
|
+
live.cancelIdle?.();
|
|
154
|
+
db.update(sessions).set({ state: 'idle', pid: null, pgid: null, command: null }).where(eq(sessions.id, sessionId)).run();
|
|
155
|
+
try {
|
|
156
|
+
await live.conversation.close();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
log.error('closing a conversation failed', error);
|
|
160
|
+
}
|
|
161
|
+
onLiveChange();
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
/** Readies a session to be archived or deleted: refuses an unknown one, and one working or waiting for an approval; closes an idle live one. */
|
|
165
|
+
async function settle(sessionId, verb) {
|
|
166
|
+
if (row(sessionId).state === 'preparing')
|
|
167
|
+
throw new ChatRefused(`This session is being prepared; wait for it to finish, then ${verb} it`);
|
|
168
|
+
const live = lives.get(sessionId);
|
|
169
|
+
if (live === undefined)
|
|
170
|
+
return;
|
|
171
|
+
if (live.state !== 'idle')
|
|
172
|
+
throw new ChatRefused(`This session is ${live.state === 'working' ? 'working' : 'waiting for an approval'}; interrupt or close it first, then ${verb} it`);
|
|
173
|
+
await close({ sessionId });
|
|
174
|
+
if (lives.has(sessionId))
|
|
175
|
+
throw new ChatRefused('This session was resumed meanwhile; close it first'); // a resume that finished during the close
|
|
176
|
+
}
|
|
177
|
+
async function openConversation(chosen, runner, args) {
|
|
178
|
+
try {
|
|
179
|
+
return await runner.open({ ...args, command: chosen.command, env });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof ChatRefused)
|
|
183
|
+
throw error;
|
|
184
|
+
log.error('a chat runner failed to open', error);
|
|
185
|
+
throw new ChatRefused(`${chosen.name} could not be started`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Removes the session with all its events and turns. A live idle session is closed first; a working or waiting one is refused. */
|
|
189
|
+
async function deleteSession({ sessionId }) {
|
|
190
|
+
// marked before the first await, so a resume that comes in meanwhile is refused
|
|
191
|
+
deleting.add(sessionId);
|
|
192
|
+
try {
|
|
193
|
+
await settle(sessionId, 'delete');
|
|
194
|
+
const { workspaceKind, worktreeBranch, worktreeFrom, worktreeBase, worktreePath } = row(sessionId);
|
|
195
|
+
// The worktree goes first: one that cannot be removed stops the deletion and the session stays.
|
|
196
|
+
if (workspaceKind === 'worktree')
|
|
197
|
+
await deleteWorktree(git, { path: worktreePath, branch: worktreeBranch, from: worktreeFrom, base: worktreeBase });
|
|
198
|
+
db.transaction((tx) => {
|
|
199
|
+
tx.delete(chatEvents).where(eq(chatEvents.sessionId, sessionId)).run();
|
|
200
|
+
tx.delete(chatTurns).where(eq(chatTurns.sessionId, sessionId)).run();
|
|
201
|
+
tx.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
deleting.delete(sessionId);
|
|
206
|
+
}
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
/** Makes a conversation the live one of a session. */
|
|
210
|
+
const attach = (sessionId, conversation, state = 'idle') => {
|
|
211
|
+
const live = { conversation, state, streaming: '', approvals: new Map() };
|
|
212
|
+
lives.set(sessionId, live);
|
|
213
|
+
const { pid = null, pgid = null, command = null } = conversation.process ?? {};
|
|
214
|
+
db.update(sessions).set({ pid, pgid, command }).where(eq(sessions.id, sessionId)).run();
|
|
215
|
+
conversation.onEvent((event) => {
|
|
216
|
+
try {
|
|
217
|
+
onEvent(sessionId, live, event);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
log.error('a chat event could not be handled', error);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// An agent that ends on its own ends the turn that was running (error) and then the live session, as a close does.
|
|
224
|
+
conversation.onEnd(() => {
|
|
225
|
+
if (lives.get(sessionId) !== live)
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
if (live.turn !== undefined)
|
|
229
|
+
onEvent(sessionId, live, { kind: 'turn_end', outcome: 'error' }); // the adapter normally said so already
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
log.error('the end of a turn could not be recorded', error);
|
|
233
|
+
}
|
|
234
|
+
close({ sessionId }).catch((error) => log.error('dropping a session whose agent ended failed', error));
|
|
235
|
+
});
|
|
236
|
+
armIdle(sessionId, live);
|
|
237
|
+
onLiveChange();
|
|
238
|
+
return live;
|
|
239
|
+
};
|
|
240
|
+
/** The name the repository lists for the branch the plan picked. A repository the plan cloned lists its other branches as `origin/<name>`, while the draft read them from GitHub as plain names. */
|
|
241
|
+
const listedAs = async (folder, { workspace }, branch) => {
|
|
242
|
+
if (workspace.kind !== 'repo')
|
|
243
|
+
return branch;
|
|
244
|
+
const { branches } = await inspectFolder(git, folder);
|
|
245
|
+
return branches.some((listed) => listed.name === branch) ? branch : `origin/${branch}`;
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* The `worktree` or `checkout` step of a plan, when it has one: the folder the agent runs in, or undefined once the step failed and was recorded.
|
|
249
|
+
* A worktree already recorded on the session is the one made before, so it is reused and not made twice; so is a checkout already done.
|
|
250
|
+
*/
|
|
251
|
+
async function arrange(sessionId, folder, plan, signal) {
|
|
252
|
+
const { worktree, branch } = plan;
|
|
253
|
+
if (worktree === undefined && branch === undefined)
|
|
254
|
+
return folder;
|
|
255
|
+
const step = worktree === undefined ? 'checkout' : 'worktree';
|
|
256
|
+
const recorded = row(sessionId);
|
|
257
|
+
if (recorded.workspaceKind === 'worktree')
|
|
258
|
+
return recorded.worktreePath;
|
|
259
|
+
if (step === 'checkout' && lastStatus(sessionId, step) === 'done')
|
|
260
|
+
return folder; // a retry does not switch the branch again
|
|
261
|
+
stepEvent(sessionId, step, 'running');
|
|
262
|
+
try {
|
|
263
|
+
if (worktree !== undefined) {
|
|
264
|
+
if (branch === undefined)
|
|
265
|
+
throw new ChatRefused('A worktree needs the branch it starts from');
|
|
266
|
+
// an empty name is the host's to generate
|
|
267
|
+
const made = await exclusive(async () => createWorktree(git, folder, { from: await listedAs(folder, plan, branch), ...(worktree.name !== undefined && worktree.name !== '' && { name: worktree.name }) }, signal));
|
|
268
|
+
db.update(sessions).set({ folder: made.path, workspaceKind: 'worktree', worktreeBranch: made.branch, worktreeFrom: made.from, worktreeBase: made.base, worktreePath: made.path }).where(eq(sessions.id, sessionId)).run();
|
|
269
|
+
stepEvent(sessionId, step, 'done');
|
|
270
|
+
return made.path;
|
|
271
|
+
}
|
|
272
|
+
await checkout(git, folder, await listedAs(folder, plan, branch));
|
|
273
|
+
stepEvent(sessionId, step, 'done');
|
|
274
|
+
return folder;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error instanceof ChatRefused === false)
|
|
278
|
+
log.error(`preparing a session failed at ${step}`, error);
|
|
279
|
+
failStep(sessionId, step, error instanceof ChatRefused ? error.message : `The ${step} could not be done`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** The `project` step of a plan: makes the folder with `git init` and records it as the session's. False once the step failed and was recorded. A project already made by an earlier try is reused. */
|
|
283
|
+
async function makeProject(sessionId, name) {
|
|
284
|
+
if (lastStatus(sessionId, 'project') === 'done')
|
|
285
|
+
return true;
|
|
286
|
+
stepEvent(sessionId, 'project', 'running');
|
|
287
|
+
try {
|
|
288
|
+
const { folder } = await createProject(git, name);
|
|
289
|
+
db.update(sessions).set({ folder }).where(eq(sessions.id, sessionId)).run();
|
|
290
|
+
stepEvent(sessionId, 'project', 'done');
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error instanceof ChatRefused === false)
|
|
295
|
+
log.error('preparing a session failed at project', error);
|
|
296
|
+
failStep(sessionId, 'project', error instanceof ChatRefused ? error.message : 'The project could not be made');
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The `clone` step of a plan for a repository, or `fetch` when it is already in the projects folder. False once the step failed and was recorded. A clone or a fetch already done by an earlier try is reused. */
|
|
301
|
+
async function bring(sessionId, repo, signal) {
|
|
302
|
+
if (lastStatus(sessionId, 'clone') === 'done' || lastStatus(sessionId, 'fetch') === 'done')
|
|
303
|
+
return true;
|
|
304
|
+
const step = projects.stepOf(repo);
|
|
305
|
+
stepEvent(sessionId, step, 'running');
|
|
306
|
+
try {
|
|
307
|
+
await projects.cloneRepo({ repo }, signal, (line) => void progress.set(sessionId, line));
|
|
308
|
+
stepEvent(sessionId, step, 'done');
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error instanceof ChatRefused === false)
|
|
313
|
+
log.error(`preparing a session failed at ${step}`, error);
|
|
314
|
+
failStep(sessionId, step, error instanceof ChatRefused ? error.message : `The ${step} could not be done`);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
progress.delete(sessionId);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** The steps of a plan, in the background: it never throws, a failure is recorded as a step that failed. */
|
|
322
|
+
async function prepare(sessionId, chosen, runner, base, plan, signal) {
|
|
323
|
+
try {
|
|
324
|
+
if (plan.workspace.kind === 'project' && (await makeProject(sessionId, plan.workspace.name)) === false)
|
|
325
|
+
return;
|
|
326
|
+
if (plan.workspace.kind === 'repo' && (await bring(sessionId, plan.workspace.repo, signal)) === false)
|
|
327
|
+
return;
|
|
328
|
+
const folder = await arrange(sessionId, plan.workspace.kind === 'project' ? row(sessionId).folder : base, plan, signal);
|
|
329
|
+
if (folder === undefined || signal.aborted)
|
|
330
|
+
return;
|
|
331
|
+
stepEvent(sessionId, 'agent', 'running');
|
|
332
|
+
const options = { ...(chosen.model && { model: chosen.model }), ...chosen.options, ...plan.options };
|
|
333
|
+
let conversation;
|
|
334
|
+
try {
|
|
335
|
+
conversation = await openConversation(chosen, runner, { folder, options });
|
|
336
|
+
for (const [id, value] of Object.entries(options))
|
|
337
|
+
checkOption(conversation.options, id, value);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
await conversation?.close().catch((closing) => log.error('closing a refused conversation failed', closing));
|
|
341
|
+
if (error instanceof ChatRefused === false)
|
|
342
|
+
log.error('preparing a session failed at the agent', error);
|
|
343
|
+
return failStep(sessionId, 'agent', error instanceof ChatRefused ? error.message : 'The agent could not be started');
|
|
344
|
+
}
|
|
345
|
+
if (signal.aborted)
|
|
346
|
+
return void (await conversation.close().catch((closing) => log.error('closing the agent of a cancelled preparation failed', closing)));
|
|
347
|
+
stepEvent(sessionId, 'agent', 'done');
|
|
348
|
+
const live = attach(sessionId, conversation, 'preparing');
|
|
349
|
+
stepEvent(sessionId, 'message', 'running');
|
|
350
|
+
live.awaitingAnswer = true;
|
|
351
|
+
live.turn = { startedAt: clock.now(), toolCalls: 0 };
|
|
352
|
+
// a retry sends the same message again, which the conversation already shows
|
|
353
|
+
if (db.select({ id: chatEvents.id }).from(chatEvents).where(and(eq(chatEvents.sessionId, sessionId), eq(chatEvents.kind, 'user'))).limit(1).get() === undefined)
|
|
354
|
+
store(sessionId, { kind: 'user', text: plan.text });
|
|
355
|
+
try {
|
|
356
|
+
conversation.send(plan.text);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
log.error('the first message could not be sent to the agent', error);
|
|
360
|
+
live.awaitingAnswer = false;
|
|
361
|
+
live.turn = undefined;
|
|
362
|
+
live.state = 'idle';
|
|
363
|
+
failStep(sessionId, 'message', 'The message could not be delivered to the agent');
|
|
364
|
+
await close({ sessionId });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
log.error('preparing a session failed', error);
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
preparing.delete(sessionId);
|
|
372
|
+
onLiveChange();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** What a plan needs to run, refusing in words one that cannot. A project's or a repository's folder is the one it will be made or cloned at. */
|
|
376
|
+
const targetOf = (plan) => {
|
|
377
|
+
const chosen = agents.find((candidate) => candidate.name === plan.agent);
|
|
378
|
+
if (chosen === undefined)
|
|
379
|
+
throw new ChatRefused(`There is no chat agent named "${plan.agent}"`);
|
|
380
|
+
const runner = runners[chosen.harness];
|
|
381
|
+
if (runner === undefined)
|
|
382
|
+
throw new ChatRefused(`This host has no chat runner for ${chosen.harness}`);
|
|
383
|
+
return { chosen, runner, folder: plan.workspace.kind === 'project' ? projectFolder(plan.workspace.name) : plan.workspace.kind === 'repo' ? projects.repoFolder(plan.workspace.repo) : checkFolder(plan.workspace.folder, carrierHome()) };
|
|
384
|
+
};
|
|
385
|
+
/** Runs the steps of a plan in the background. */
|
|
386
|
+
const begin = (sessionId, { chosen, runner, folder }, plan) => {
|
|
387
|
+
const running = { abort: new AbortController(), done: Promise.resolve() };
|
|
388
|
+
preparing.set(sessionId, running);
|
|
389
|
+
running.done = prepare(sessionId, chosen, runner, folder, plan, running.abort.signal);
|
|
390
|
+
};
|
|
391
|
+
/** What each agent offered when it was last asked (a draft lists the models before a session exists), kept ten minutes. */
|
|
392
|
+
const offered = new Map();
|
|
393
|
+
return {
|
|
394
|
+
liveCount: () => new Set([...lives.keys(), ...preparing.keys()]).size,
|
|
395
|
+
clientJoined() {
|
|
396
|
+
clients++;
|
|
397
|
+
for (const live of lives.values())
|
|
398
|
+
live.cancelIdle?.();
|
|
399
|
+
},
|
|
400
|
+
clientLeft() {
|
|
401
|
+
clients--;
|
|
402
|
+
for (const [sessionId, live] of lives)
|
|
403
|
+
armIdle(sessionId, live);
|
|
404
|
+
},
|
|
405
|
+
agents: () => agents.map(({ name, harness, model, command }) => ({ name, harness, model, found: binaryFound(command ?? harness) })),
|
|
406
|
+
health,
|
|
407
|
+
/** The rows, each with `kept` (spared by the clock for unsaved work, only while it is due under the current ages) and `deleteAt` (when the clock deletes it, in ms; null unless archived, with both ages set and not kept). */
|
|
408
|
+
list: ({ archived = false }) => {
|
|
409
|
+
const { archiveAfterDays, deleteAfterDays } = ages();
|
|
410
|
+
return {
|
|
411
|
+
sessions: db.select().from(sessions).where(eq(sessions.archived, archived)).orderBy(desc(sessions.lastActiveAt)).all().map((found) => {
|
|
412
|
+
const dueAt = found.archived && archiveAfterDays !== 'never' && deleteAfterDays !== 'never' ? found.lastActiveAt + deleteAfterDays * DAY : null;
|
|
413
|
+
const kept = found.keptAt !== null && dueAt !== null && clock.now() >= dueAt; // a session the clock no longer considers (ages raised or never) is not kept
|
|
414
|
+
return { ...summary(found), live: lives.has(found.id), kept, deleteAt: kept ? null : dueAt };
|
|
415
|
+
}),
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
async open({ agent, folder, options, worktree }) {
|
|
419
|
+
const chosen = agents.find((candidate) => candidate.name === agent);
|
|
420
|
+
if (chosen === undefined)
|
|
421
|
+
throw new ChatRefused(`There is no chat agent named "${agent}"`);
|
|
422
|
+
const real = checkFolder(folder, carrierHome());
|
|
423
|
+
const runner = runners[chosen.harness];
|
|
424
|
+
if (runner === undefined)
|
|
425
|
+
throw new ChatRefused(`This host has no chat runner for ${chosen.harness}`);
|
|
426
|
+
const chosenOptions = { ...(chosen.model && { model: chosen.model }), ...chosen.options, ...options };
|
|
427
|
+
const made = worktree === undefined ? undefined : await exclusive(() => createWorktree(git, real, worktree));
|
|
428
|
+
let conversation;
|
|
429
|
+
let created;
|
|
430
|
+
// One step: a worktree whose session could not be created is taken away again.
|
|
431
|
+
try {
|
|
432
|
+
conversation = await openConversation(chosen, runner, { folder: made?.path ?? real, options: chosenOptions });
|
|
433
|
+
for (const [id, value] of Object.entries(chosenOptions))
|
|
434
|
+
checkOption(conversation.options, id, value);
|
|
435
|
+
const now = clock.now();
|
|
436
|
+
created = db
|
|
437
|
+
.insert(sessions)
|
|
438
|
+
.values({
|
|
439
|
+
id: randomUUID(),
|
|
440
|
+
agent: chosen.name,
|
|
441
|
+
harness: chosen.harness,
|
|
442
|
+
folder: made?.path ?? real,
|
|
443
|
+
...(made !== undefined && { workspaceKind: 'worktree', worktreeBranch: made.branch, worktreeFrom: made.from, worktreeBase: made.base, worktreePath: made.path }),
|
|
444
|
+
options: chosenOptions,
|
|
445
|
+
nativeId: conversation.nativeId ?? null,
|
|
446
|
+
createdAt: now,
|
|
447
|
+
lastActiveAt: now,
|
|
448
|
+
})
|
|
449
|
+
.returning()
|
|
450
|
+
.get();
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
await conversation?.close().catch((closing) => log.error('closing a refused conversation failed', closing));
|
|
454
|
+
if (made !== undefined)
|
|
455
|
+
await removeWorktree(git, made);
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
attach(created.id, conversation);
|
|
459
|
+
return { session: summary(created), options: conversation.options };
|
|
460
|
+
},
|
|
461
|
+
/**
|
|
462
|
+
* Opens a session from a plan: it exists at once as `preparing`, and the steps run in the background and record their events.
|
|
463
|
+
*/
|
|
464
|
+
async openPlan({ plan }) {
|
|
465
|
+
if (plan.text.length > chatCaps.message)
|
|
466
|
+
throw new ChatRefused(`That message has ${plan.text.length} characters; the most a message can have is ${chatCaps.message}`);
|
|
467
|
+
const target = targetOf(plan);
|
|
468
|
+
const now = clock.now();
|
|
469
|
+
const created = db
|
|
470
|
+
.insert(sessions)
|
|
471
|
+
.values({ id: randomUUID(), agent: target.chosen.name, harness: target.chosen.harness, folder: target.folder, plan, title: titleOf(plan.text), state: 'preparing', createdAt: now, lastActiveAt: now })
|
|
472
|
+
.returning()
|
|
473
|
+
.get();
|
|
474
|
+
begin(created.id, target, plan); // before the answer, so the host is never idle between the reply and the first step
|
|
475
|
+
return { session: summary(created) };
|
|
476
|
+
},
|
|
477
|
+
/** Repeats the steps of a failed preparation that did not finish; what was made (the worktree, the checkout) is reused. */
|
|
478
|
+
async retryPreparation({ sessionId }) {
|
|
479
|
+
const current = row(sessionId);
|
|
480
|
+
if (current.state === 'preparing')
|
|
481
|
+
throw new ChatRefused('This session is still being prepared, so there is nothing to try again');
|
|
482
|
+
if (current.plan === null || preparing.has(sessionId))
|
|
483
|
+
throw new ChatRefused('This session is not in a failed preparation, so there is nothing to try again');
|
|
484
|
+
if (deleting.has(sessionId))
|
|
485
|
+
throw new ChatRefused('This session is being deleted');
|
|
486
|
+
const target = targetOf(current.plan);
|
|
487
|
+
const updated = db.update(sessions).set({ state: 'preparing', lastActiveAt: clock.now() }).where(eq(sessions.id, sessionId)).returning().get();
|
|
488
|
+
begin(sessionId, target, current.plan);
|
|
489
|
+
return { session: summary(updated) };
|
|
490
|
+
},
|
|
491
|
+
/** Stops the step that is running and deletes the session as a delete does, its worktree with it; answers the plan, for the app to put back in the draft. */
|
|
492
|
+
async cancelPreparation({ sessionId }) {
|
|
493
|
+
const { state, plan } = row(sessionId);
|
|
494
|
+
if (state !== 'preparing')
|
|
495
|
+
throw new ChatRefused('This session is not being prepared, so there is nothing to cancel');
|
|
496
|
+
deleting.add(sessionId); // a retry or a resume that comes in meanwhile is refused
|
|
497
|
+
try {
|
|
498
|
+
const running = preparing.get(sessionId);
|
|
499
|
+
running?.abort.abort();
|
|
500
|
+
await running?.done;
|
|
501
|
+
await close({ sessionId }); // the agent that waits to answer the first message
|
|
502
|
+
db.update(sessions).set({ state: 'idle' }).where(eq(sessions.id, sessionId)).run();
|
|
503
|
+
await deleteSession({ sessionId });
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
deleting.delete(sessionId);
|
|
507
|
+
}
|
|
508
|
+
return { plan };
|
|
509
|
+
},
|
|
510
|
+
/**
|
|
511
|
+
* The options and the commands an agent offers, with the values it would start with, before any session exists: it starts the agent in a folder of
|
|
512
|
+
* nobody's, reads what it lists and ends it. Kept ten minutes, and a failure is not kept.
|
|
513
|
+
*/
|
|
514
|
+
async agentOptions({ agent }) {
|
|
515
|
+
const chosen = agents.find((candidate) => candidate.name === agent);
|
|
516
|
+
if (chosen === undefined)
|
|
517
|
+
throw new ChatRefused(`There is no chat agent named "${agent}"`);
|
|
518
|
+
const runner = runners[chosen.harness];
|
|
519
|
+
if (runner === undefined)
|
|
520
|
+
throw new ChatRefused(`This host has no chat runner for ${chosen.harness}`);
|
|
521
|
+
const known = offered.get(agent);
|
|
522
|
+
if (known !== undefined && clock.now() - known.at < 10 * 60_000)
|
|
523
|
+
return await known.offer;
|
|
524
|
+
const offer = (async () => {
|
|
525
|
+
const conversation = await openConversation(chosen, runner, { folder: tmpdir(), options: { ...(chosen.model && { model: chosen.model }), ...chosen.options } });
|
|
526
|
+
try {
|
|
527
|
+
return { options: [...conversation.options], commands: [...conversation.commands] };
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
await conversation.close().catch((error) => log.error('closing the agent that listed its options failed', error));
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
533
|
+
offered.set(agent, { at: clock.now(), offer });
|
|
534
|
+
try {
|
|
535
|
+
return await offer;
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
offered.delete(agent);
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
/** Opens a closed session again from its stored native id, folder and options; a live one is just attached to. */
|
|
543
|
+
async resume({ sessionId }) {
|
|
544
|
+
const current = row(sessionId);
|
|
545
|
+
if (current.plan !== null)
|
|
546
|
+
throw new ChatRefused('This session did not finish preparing');
|
|
547
|
+
const present = lives.get(sessionId);
|
|
548
|
+
if (present !== undefined)
|
|
549
|
+
return { session: summary(current), options: present.conversation.options };
|
|
550
|
+
if (deleting.has(sessionId))
|
|
551
|
+
throw new ChatRefused('This session is being deleted');
|
|
552
|
+
const chosen = agents.find((candidate) => candidate.name === current.agent);
|
|
553
|
+
if (chosen === undefined)
|
|
554
|
+
throw new ChatRefused(`The agent "${current.agent}" of this session is not configured any more`);
|
|
555
|
+
const runner = runners[chosen.harness];
|
|
556
|
+
if (runner === undefined)
|
|
557
|
+
throw new ChatRefused(`This host has no chat runner for ${chosen.harness}`);
|
|
558
|
+
const folder = checkFolder(current.folder, carrierHome());
|
|
559
|
+
// a session that never got a native id (nothing was said) has nothing to resume: it starts again, as it was opened
|
|
560
|
+
const conversation = await openConversation(chosen, runner, { folder, options: current.options ?? {}, ...(current.nativeId !== null && { resume: current.nativeId }) });
|
|
561
|
+
if (lives.has(sessionId)) {
|
|
562
|
+
// another client resumed it while this one waited for the provider
|
|
563
|
+
await conversation.close();
|
|
564
|
+
return { session: summary(row(sessionId)), options: lives.get(sessionId).conversation.options };
|
|
565
|
+
}
|
|
566
|
+
if (deleting.has(sessionId)) {
|
|
567
|
+
// the session was deleted while this one waited for the provider: nothing may stay attached to its folder
|
|
568
|
+
await conversation.close();
|
|
569
|
+
throw new ChatRefused('This session is being deleted');
|
|
570
|
+
}
|
|
571
|
+
attach(sessionId, conversation);
|
|
572
|
+
return { session: summary(current), options: conversation.options };
|
|
573
|
+
},
|
|
574
|
+
send({ sessionId, text, command }) {
|
|
575
|
+
if (text.length > chatCaps.message)
|
|
576
|
+
throw new ChatRefused(`That message has ${text.length} characters; the most a message can have is ${chatCaps.message}`);
|
|
577
|
+
const current = row(sessionId);
|
|
578
|
+
const live = lives.get(sessionId);
|
|
579
|
+
if (live === undefined)
|
|
580
|
+
throw new ChatRefused('This session is closed');
|
|
581
|
+
if (live.state !== 'idle')
|
|
582
|
+
throw new ChatRefused('The agent is still answering');
|
|
583
|
+
// a command is named by the id the session listed; what the person sees of it is a slash and its name, whatever the provider does with it
|
|
584
|
+
const invoked = command === undefined ? undefined : live.conversation.commands.find((candidate) => candidate.id === command);
|
|
585
|
+
if (command !== undefined && invoked === undefined)
|
|
586
|
+
throw new ChatRefused('This session does not list that command');
|
|
587
|
+
const shown = invoked === undefined ? text : `/${invoked.name}${text === '' ? '' : ` ${text}`}`;
|
|
588
|
+
if (shown.length > chatCaps.message)
|
|
589
|
+
throw new ChatRefused(`That message has ${shown.length} characters; the most a message can have is ${chatCaps.message}`);
|
|
590
|
+
if (current.title === null)
|
|
591
|
+
db.update(sessions).set({ title: titleOf(shown) }).where(eq(sessions.id, sessionId)).run();
|
|
592
|
+
live.turn = { startedAt: clock.now(), toolCalls: 0 };
|
|
593
|
+
store(sessionId, { kind: 'user', text: shown, ...(invoked !== undefined && { command: invoked.name }) });
|
|
594
|
+
touch(sessionId, live, 'working');
|
|
595
|
+
try {
|
|
596
|
+
live.conversation.send(text, invoked);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
log.error('a message could not be sent to the agent', error);
|
|
600
|
+
live.turn = undefined;
|
|
601
|
+
touch(sessionId, live, 'idle');
|
|
602
|
+
throw new ChatRefused('The message could not be delivered to the agent');
|
|
603
|
+
}
|
|
604
|
+
return {};
|
|
605
|
+
},
|
|
606
|
+
/** Passes the person's choice for a pending approval to the agent; the adapter says it was answered and the host records that. */
|
|
607
|
+
answer({ sessionId, approvalId, choiceId }) {
|
|
608
|
+
row(sessionId);
|
|
609
|
+
const live = lives.get(sessionId);
|
|
610
|
+
if (live === undefined)
|
|
611
|
+
throw new ChatRefused('This session is closed');
|
|
612
|
+
const approval = live.approvals.get(approvalId);
|
|
613
|
+
if (approval === undefined)
|
|
614
|
+
throw new ChatRefused('That approval is not pending: it is unknown or was already answered');
|
|
615
|
+
if (approval.choices.some((choice) => choice.id === choiceId) === false)
|
|
616
|
+
throw new ChatRefused(`"${choiceId}" is not one of the choices: ${approval.choices.map((choice) => choice.id).join(', ')}`);
|
|
617
|
+
live.approvals.delete(approvalId); // before the agent hears it, so a second answer finds nothing
|
|
618
|
+
try {
|
|
619
|
+
live.conversation.answer(approvalId, choiceId);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
live.approvals.set(approvalId, approval);
|
|
623
|
+
if (error instanceof ChatRefused)
|
|
624
|
+
throw error;
|
|
625
|
+
log.error('an answer could not be delivered to the agent', error);
|
|
626
|
+
throw new ChatRefused('The answer could not be delivered to the agent');
|
|
627
|
+
}
|
|
628
|
+
return {};
|
|
629
|
+
},
|
|
630
|
+
/** Ends the turn that is running, if any, and leaves the session idle for the next message. With no turn running it changes nothing. */
|
|
631
|
+
async interrupt({ sessionId }) {
|
|
632
|
+
row(sessionId);
|
|
633
|
+
const live = lives.get(sessionId);
|
|
634
|
+
if (live?.turn === undefined)
|
|
635
|
+
return {};
|
|
636
|
+
try {
|
|
637
|
+
await live.conversation.interrupt();
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
log.error('interrupting a conversation failed', error);
|
|
641
|
+
}
|
|
642
|
+
// The adapter says the turn ended; if it did not, the interrupt still did, so the host records it.
|
|
643
|
+
if (live.turn !== undefined)
|
|
644
|
+
onEvent(sessionId, live, { kind: 'turn_end', outcome: 'interrupted' });
|
|
645
|
+
return {};
|
|
646
|
+
},
|
|
647
|
+
async setOption({ sessionId, option, value }) {
|
|
648
|
+
row(sessionId);
|
|
649
|
+
const live = lives.get(sessionId);
|
|
650
|
+
if (live === undefined)
|
|
651
|
+
throw new ChatRefused('This session is closed');
|
|
652
|
+
checkOption(live.conversation.options, option, value);
|
|
653
|
+
try {
|
|
654
|
+
await live.conversation.setOption(option, value);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (error instanceof ChatRefused)
|
|
658
|
+
throw error;
|
|
659
|
+
log.error('an option could not be changed', error);
|
|
660
|
+
throw new ChatRefused(`The option "${option}" could not be changed`);
|
|
661
|
+
}
|
|
662
|
+
return { options: live.conversation.options };
|
|
663
|
+
},
|
|
664
|
+
events({ sessionId, after = 0, limit }) {
|
|
665
|
+
const current = row(sessionId);
|
|
666
|
+
const rows = db.select().from(chatEvents).where(and(eq(chatEvents.sessionId, sessionId), gt(chatEvents.id, after))).orderBy(asc(chatEvents.id)).limit(limit).all();
|
|
667
|
+
const live = lives.get(sessionId);
|
|
668
|
+
return {
|
|
669
|
+
// an option event's data has the option's own `id`, which must not take the place of the row's: a client reads `id` as the cursor
|
|
670
|
+
events: rows.map(({ id, at, kind, data }) => (kind === 'option' ? { id, at, kind, option: data.id, value: data.value } : { id, at, kind, ...data })),
|
|
671
|
+
cursor: rows.at(-1)?.id ?? after,
|
|
672
|
+
session: summary(current),
|
|
673
|
+
streaming: live?.streaming ?? '',
|
|
674
|
+
progress: progress.get(sessionId) ?? null,
|
|
675
|
+
approvals: [...(live?.approvals.values() ?? [])],
|
|
676
|
+
options: live?.conversation.options ?? [],
|
|
677
|
+
commands: live?.conversation.commands ?? [],
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
close,
|
|
681
|
+
/** Sets or clears the archived mark. A live idle session is closed first; a working or waiting one is refused. */
|
|
682
|
+
async archive({ sessionId, archived }) {
|
|
683
|
+
if (archived)
|
|
684
|
+
await settle(sessionId, 'archive');
|
|
685
|
+
else
|
|
686
|
+
row(sessionId);
|
|
687
|
+
db.update(sessions).set({ archived, ...(archived === false && { lastActiveAt: clock.now(), keptAt: null }) }).where(eq(sessions.id, sessionId)).run();
|
|
688
|
+
return {};
|
|
689
|
+
},
|
|
690
|
+
/**
|
|
691
|
+
* The clock's pass: archives each session left alone for `archiveAfterDays`, then deletes each archived one left alone for `deleteAfterDays`, sparing
|
|
692
|
+
* (marking `keptAt`) one whose worktree holds unsaved work. A working, waiting or live one is looked at again on the next pass.
|
|
693
|
+
*/
|
|
694
|
+
async applyClock({ archiveAfterDays, deleteAfterDays }) {
|
|
695
|
+
const now = clock.now();
|
|
696
|
+
for (const found of db.select().from(sessions).where(eq(sessions.archived, false)).all()) {
|
|
697
|
+
if (idleFor(found, lives.has(found.id), now, archiveAfterDays))
|
|
698
|
+
db.update(sessions).set({ archived: true }).where(eq(sessions.id, found.id)).run();
|
|
699
|
+
}
|
|
700
|
+
if (archiveAfterDays === 'never')
|
|
701
|
+
return;
|
|
702
|
+
for (const { id } of db.select().from(sessions).where(eq(sessions.archived, true)).all()) {
|
|
703
|
+
if (closed)
|
|
704
|
+
return;
|
|
705
|
+
try {
|
|
706
|
+
// read again after each wait: the person may have restored or resumed it meanwhile, or the host may be shutting down
|
|
707
|
+
const due = () => {
|
|
708
|
+
if (closed)
|
|
709
|
+
return undefined;
|
|
710
|
+
const found = db.select().from(sessions).where(eq(sessions.id, id)).get();
|
|
711
|
+
return found?.archived === true && idleFor(found, lives.has(id), now, deleteAfterDays) ? found : undefined;
|
|
712
|
+
};
|
|
713
|
+
const found = due();
|
|
714
|
+
if (found === undefined)
|
|
715
|
+
continue;
|
|
716
|
+
if (found.workspaceKind === 'worktree') {
|
|
717
|
+
// one session at a time and just before deleting it, so a commit shared with one deleted a moment ago counts as lost
|
|
718
|
+
const { dirty, unpushed } = await previewWorktree(git, { path: found.worktreePath, branch: found.worktreeBranch, from: found.worktreeFrom, base: found.worktreeBase }, [found.worktreeBranch]);
|
|
719
|
+
if (due() === undefined)
|
|
720
|
+
continue;
|
|
721
|
+
if (dirty > 0 || unpushed > 0) {
|
|
722
|
+
if (found.keptAt === null)
|
|
723
|
+
db.update(sessions).set({ keptAt: now }).where(eq(sessions.id, id)).run();
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
await deleteSession({ sessionId: id });
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
log.error('the clock could not delete a session; the next pass tries again', error); // git cannot read it, or it cannot be removed: left alone
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
/** Removes the session with all its events and turns. A live idle session is closed first; a working or waiting one is refused. */
|
|
735
|
+
delete: deleteSession,
|
|
736
|
+
/** What deleting these sessions would discard: for each one in a worktree, its uncommitted files and the commits that exist nowhere else. An unknown id refuses the whole request. */
|
|
737
|
+
async deletePreview({ sessionIds }) {
|
|
738
|
+
const found = sessionIds.map(row);
|
|
739
|
+
// A commit shared by two of them is lost with both, so each one's preview excludes every branch of the batch.
|
|
740
|
+
const batch = found.flatMap(({ workspaceKind, worktreeBranch }) => (workspaceKind === 'worktree' ? [worktreeBranch] : []));
|
|
741
|
+
// One session after another: a worktree reads two git processes, and the preview is a one-off, not a hot path.
|
|
742
|
+
const previews = [];
|
|
743
|
+
for (const { id, workspaceKind, worktreeBranch, worktreeFrom, worktreeBase, worktreePath } of found) {
|
|
744
|
+
previews.push({
|
|
745
|
+
sessionId: id,
|
|
746
|
+
worktree: workspaceKind === 'worktree' ? { path: worktreePath, branch: worktreeBranch, ...(await previewWorktree(git, { path: worktreePath, branch: worktreeBranch, from: worktreeFrom, base: worktreeBase }, batch)) } : null,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
return { sessions: previews };
|
|
750
|
+
},
|
|
751
|
+
/** What a folder is as a git repository, for the person to choose how to work in it. */
|
|
752
|
+
workspace: async ({ folder }) => inspectFolder(git, checkFolder(folder, carrierHome())),
|
|
753
|
+
/** Fetches `origin` of a folder and answers what it is after it. */
|
|
754
|
+
fetch: async ({ folder }) => fetchFolder(git, checkFolder(folder, carrierHome())),
|
|
755
|
+
/** A free name for a worktree of a folder, from the StarCraft word lists. */
|
|
756
|
+
suggestName: async ({ folder }) => suggestName(git, checkFolder(folder, carrierHome())),
|
|
757
|
+
/** Whether a typed worktree name is valid and free. */
|
|
758
|
+
checkName: async ({ folder, name }) => checkName(git, checkFolder(folder, carrierHome()), name),
|
|
759
|
+
/** Closes every live session: the host's stop. */
|
|
760
|
+
async closeAll() {
|
|
761
|
+
closed = true;
|
|
762
|
+
for (const sessionId of [...lives.keys()])
|
|
763
|
+
await close({ sessionId });
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|