golem-kit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +19 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
package/src/dev-server.ts
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { createServer, type Server } from 'node:http';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { extname, join, normalize, resolve } from 'node:path';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
6
|
import { buildBrowser, rebuild } from './browser-build.ts';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { ConversationState } from './runtime/state.ts';
|
|
7
|
+
import { createAppBackend, type AppBackend } from './backend/http.ts';
|
|
8
|
+
import { ordinaryChat, type OrdinaryChat } from './chat.ts';
|
|
9
|
+
import { openBrain } from './brain.ts';
|
|
10
10
|
import { serverUrl } from './config.ts';
|
|
11
|
+
import { discoverAgents, runtimeState, type AgentName } from './runtime/discovery.ts';
|
|
12
|
+
import { SessionManager, type Session, type SessionBackend, type SessionSnapshot } from './runtime/session.ts';
|
|
13
|
+
import { TmuxBackend, chatInstructions, type HarnessRef } from './runtime/tmux.ts';
|
|
14
|
+
import { ConversationState } from './runtime/state.ts';
|
|
11
15
|
|
|
12
16
|
const appRoot = resolve(process.cwd());
|
|
17
|
+
/** `ref` is the saved harness ref of a restored conversation; a new one has none. `buildMode` picks the window: `builder` or `chat`. */
|
|
18
|
+
type CreateBackend = (backend: AgentName, ref?: HarnessRef, buildMode?: boolean) => SessionBackend | Promise<SessionBackend>;
|
|
19
|
+
/** The app-wide Builder switch, kept in `.golem/builder.json` so a reload comes back in the same mode. */
|
|
20
|
+
type BuilderFlag = { get(): boolean; set(on: boolean): Promise<void> };
|
|
21
|
+
async function builderFlag(stateDirectory: string): Promise<BuilderFlag> {
|
|
22
|
+
const file = join(stateDirectory, 'builder.json');
|
|
23
|
+
let on = await readFile(file, 'utf8').then((text) => Boolean(JSON.parse(text).builder), () => false);
|
|
24
|
+
return {
|
|
25
|
+
get: () => on,
|
|
26
|
+
async set(next) { on = next; await mkdir(stateDirectory, { recursive: true }); await writeFile(file, JSON.stringify({ builder: next }) + '\n'); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
13
29
|
const root = pathToFileURL(`${process.cwd()}/dist/`);
|
|
14
30
|
const types: Record<string, string> = {
|
|
15
31
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -21,21 +37,61 @@ const types: Record<string, string> = {
|
|
|
21
37
|
// The CLI owns logging and signals; this server only serves the built browser shell.
|
|
22
38
|
export async function startDevServer(
|
|
23
39
|
port = 3000,
|
|
24
|
-
createBackend
|
|
40
|
+
createBackend?: CreateBackend,
|
|
25
41
|
stateDirectory = join(appRoot, '.golem'),
|
|
26
42
|
host = '127.0.0.1',
|
|
27
43
|
): Promise<Server> {
|
|
28
44
|
await buildBrowser();
|
|
45
|
+
// A new session needs a runnable CLI; a restored one keeps its ref and resumes on its next message.
|
|
46
|
+
createBackend ??= async (backend, ref, buildMode = true) => {
|
|
47
|
+
if (!ref && !(await discoverAgents()).some((found) => found.agent === backend && found.runnable)) throw new Error(`${backend} is not runnable here`);
|
|
48
|
+
return new TmuxBackend(appRoot, backend, ref, { stateDir: join(stateDirectory, 'harness'), api: serverUrl(host, port), window: buildMode ? 'builder' : 'chat', ...(buildMode ? {} : { instructions: chatInstructions(appRoot), permissions: 'readonly' }) });
|
|
49
|
+
};
|
|
50
|
+
const builder = await builderFlag(stateDirectory);
|
|
29
51
|
const state = new ConversationState(stateDirectory);
|
|
30
52
|
const sessions = new SessionManager((snapshots) => state.save(snapshots));
|
|
31
|
-
|
|
53
|
+
const app = await createAppBackend(appRoot, join(stateDirectory, 'data'));
|
|
54
|
+
const chat = ordinaryChat(app);
|
|
55
|
+
const brain = app.config.brain ? openBrain(join(appRoot, 'brain')) : undefined;
|
|
56
|
+
// Source views of a chat belong to whoever owns that chat.
|
|
57
|
+
app.app.views.useConversations({
|
|
58
|
+
owner: (request, principal) => chat.owner(request, principal),
|
|
59
|
+
owns: (conversation, owner) => { const session = sessions.get(conversation); return session?.backend === 'anthropic' && session.owner === owner; },
|
|
60
|
+
});
|
|
61
|
+
// Restored conversations wait for their next message; nothing is re-run.
|
|
62
|
+
const restored = await state.load();
|
|
63
|
+
const workers = await Promise.all(restored.map((snapshot) => snapshot.backend === 'anthropic'
|
|
64
|
+
? chat.backend(snapshot.transcript)
|
|
65
|
+
: createBackend(snapshot.backend, snapshot.harness as HarnessRef | undefined, snapshot.buildMode)));
|
|
66
|
+
sessions.restore(restored, (snapshot: SessionSnapshot) => workers[restored.indexOf(snapshot)]);
|
|
67
|
+
const { accounts } = app;
|
|
68
|
+
if (accounts) {
|
|
69
|
+
// Printed to the terminal that owns the data, never served: a fresh store, or a deliberate recovery.
|
|
70
|
+
const invite = await accounts.managerInvite(app.origin(new URL(serverUrl(host, port)).host), process.env.GOLEM_ADMIN_INVITE === '1');
|
|
71
|
+
if (invite) console.log(`Admin invite (one use, expires in 24 hours): ${invite}`);
|
|
72
|
+
// A build turn runs with full file access: stop it once its owner may no longer build or has signed out everywhere.
|
|
73
|
+
accounts.changes.on('change', (accountId: string) => {
|
|
74
|
+
for (const session of sessions.all()) {
|
|
75
|
+
if (session.owner !== accountId || !session.snapshot().active) continue;
|
|
76
|
+
// A chat turn acts as the browser session that sent it; it stops once that session ends.
|
|
77
|
+
if (session.backend === 'anthropic') {
|
|
78
|
+
const turn = session.activeContext;
|
|
79
|
+
if (turn) void app.app.refresh(turn.principal).then(() => {}, () => session.interrupt());
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
void accounts.resolveAccount(accountId)
|
|
83
|
+
.then(async (principal) => accounts.canBuild(principal) && await accounts.signedIn(accountId), () => false)
|
|
84
|
+
.then((allowed) => { if (!allowed) return session.interrupt(); });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
32
88
|
const server = createServer((request, response) => {
|
|
33
|
-
void handleRequest(request, response, sessions, port, host, createBackend).catch((error) => {
|
|
89
|
+
void (request.url?.startsWith('/api/app/') || request.url?.startsWith('/api/auth/') ? app.handle(request, response) : request.url?.startsWith('/api/brain/') ? handleBrain(request, response, app, brain) : handleRequest(request, response, sessions, port, host, createBackend, app, chat, builder)).catch((error) => {
|
|
34
90
|
if (!response.headersSent) json(response, 400, { error: error instanceof Error ? error.message : 'Malformed request' });
|
|
35
91
|
else response.destroy();
|
|
36
92
|
});
|
|
37
93
|
});
|
|
38
|
-
server.once('close', () => { void sessions.disposeAll().then(() => sessions.flushAll()) });
|
|
94
|
+
server.once('close', () => { void sessions.disposeAll().then(() => sessions.flushAll()).finally(() => app.close()) });
|
|
39
95
|
return new Promise((resolve, reject) => {
|
|
40
96
|
server.once('error', reject);
|
|
41
97
|
server.listen(port, host, () => resolve(server));
|
|
@@ -48,12 +104,15 @@ async function handleRequest(
|
|
|
48
104
|
sessions: SessionManager,
|
|
49
105
|
port: number,
|
|
50
106
|
host: string,
|
|
51
|
-
createBackend:
|
|
107
|
+
createBackend: CreateBackend,
|
|
108
|
+
app: AppBackend,
|
|
109
|
+
chat: OrdinaryChat,
|
|
110
|
+
builder: BuilderFlag,
|
|
52
111
|
): Promise<void> {
|
|
53
112
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
54
113
|
decodeURIComponent(url.pathname);
|
|
55
114
|
if (url.pathname.startsWith('/api/')) {
|
|
56
|
-
await handleApi(request, response, url, sessions,
|
|
115
|
+
await handleApi(request, response, url, sessions, createBackend, app, chat, builder);
|
|
57
116
|
return;
|
|
58
117
|
}
|
|
59
118
|
let pathname: string;
|
|
@@ -98,6 +157,29 @@ function json(response: import('node:http').ServerResponse, status: number, body
|
|
|
98
157
|
response.end(JSON.stringify(body));
|
|
99
158
|
}
|
|
100
159
|
|
|
160
|
+
/** The app's brain, read-only: whoever may see the app may read it. */
|
|
161
|
+
async function handleBrain(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse, app: AppBackend, brain: ReturnType<typeof openBrain> | undefined): Promise<void> {
|
|
162
|
+
if (!brain || request.method !== 'GET') return json(response, 404, { error: 'This app has no brain' });
|
|
163
|
+
const principal = await app.app.resolvePrincipal(request);
|
|
164
|
+
if (app.accounts && !app.accounts.config.guests && principal.kind === 'anonymous') return json(response, 401, { error: 'Sign in to read the brain.' });
|
|
165
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
166
|
+
const param = (name: string) => url.searchParams.get(name) ?? '';
|
|
167
|
+
switch (url.pathname) {
|
|
168
|
+
case '/api/brain/index': return json(response, 200, { text: await brain.index(param('dir')) });
|
|
169
|
+
case '/api/brain/list': return json(response, 200, { entries: await brain.list(param('dir')) });
|
|
170
|
+
case '/api/brain/read': return json(response, 200, { text: await brain.read(param('path')) });
|
|
171
|
+
case '/api/brain/search': return json(response, 200, { hits: await brain.search(param('q')) });
|
|
172
|
+
case '/api/brain/events': {
|
|
173
|
+
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
174
|
+
response.flushHeaders();
|
|
175
|
+
const stop = brain.watch(() => response.write('data: {}\n\n'));
|
|
176
|
+
request.on('close', stop);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
default: return json(response, 404, { error: 'Unknown API route' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
101
183
|
async function body(request: import('node:http').IncomingMessage): Promise<unknown> {
|
|
102
184
|
let text = '';
|
|
103
185
|
for await (const chunk of request) {
|
|
@@ -107,17 +189,73 @@ async function body(request: import('node:http').IncomingMessage): Promise<unkno
|
|
|
107
189
|
try { return JSON.parse(text || '{}'); } catch { throw new Error('Request body must be valid JSON'); }
|
|
108
190
|
}
|
|
109
191
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
)
|
|
192
|
+
/**
|
|
193
|
+
* Fires once after a successful build-mode turn; never blocks the turn's own response.
|
|
194
|
+
* The app's server module reloads before the refresh, so the new UI never talks to old operations.
|
|
195
|
+
*/
|
|
196
|
+
function triggerRebuild(session: Session, reloadServer: () => Promise<void>): void {
|
|
197
|
+
void distFingerprint().then(async (before) => {
|
|
198
|
+
await rebuild();
|
|
199
|
+
await reloadServer();
|
|
200
|
+
// Vite content-hashes asset names, so index.html changes iff the bundle did: a chat-only
|
|
201
|
+
// turn (or a server-only edit) leaves the page alone instead of reloading it.
|
|
202
|
+
if (await distFingerprint() !== before) session.notifyRebuilt();
|
|
203
|
+
}).catch((error: unknown) => session.notifyBuildFailed(error instanceof Error ? error.message : String(error)));
|
|
116
204
|
}
|
|
117
205
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
206
|
+
const distFingerprint = (): Promise<string> => readFile(new URL('index.html', root))
|
|
207
|
+
.then((html) => createHash('sha1').update(html).digest('hex'), () => '');
|
|
208
|
+
|
|
209
|
+
// ---------- pane hub: the Terminal popup's live agent screen ----------
|
|
210
|
+
// Copied from Bridge Commander's paneStream: one harness feed per session, ref-counted across
|
|
211
|
+
// browser tabs; the first subscriber opens it, the last disconnect closes it. Guards are clean
|
|
212
|
+
// SSE events then end, never a 500 (the client is an EventSource and cannot read error bodies):
|
|
213
|
+
// unsupported — this backend has no screen to show
|
|
214
|
+
// no-pane — the agent has not been spawned yet, or the open failed
|
|
215
|
+
// busy — the concurrent-feed cap is hit
|
|
216
|
+
const PANE_MAX = 8;
|
|
217
|
+
type PaneHub = { clients: Set<import('node:http').ServerResponse>; handle: { close(): void } | null; last: string | null };
|
|
218
|
+
const panes = new Map<string, PaneHub>();
|
|
219
|
+
function paneWrite(response: import('node:http').ServerResponse, event: string, data: unknown = {}): void {
|
|
220
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
221
|
+
}
|
|
222
|
+
function paneStream(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse, session: Session): void {
|
|
223
|
+
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
224
|
+
response.flushHeaders();
|
|
225
|
+
if (!session.worker.pane) { paneWrite(response, 'unsupported'); response.end(); return; }
|
|
226
|
+
const pane = session.worker.pane();
|
|
227
|
+
if (!pane) { paneWrite(response, 'no-pane', { reason: 'the agent has not started yet; send a message first' }); response.end(); return; }
|
|
228
|
+
let hub = panes.get(session.id);
|
|
229
|
+
if (!hub) {
|
|
230
|
+
if (panes.size >= PANE_MAX) { paneWrite(response, 'busy', { max: PANE_MAX }); response.end(); return; }
|
|
231
|
+
const created: PaneHub = { clients: new Set(), handle: null, last: null };
|
|
232
|
+
hub = created;
|
|
233
|
+
panes.set(session.id, created);
|
|
234
|
+
console.log(`[pane] openPane ${session.id}`);
|
|
235
|
+
Promise.resolve(pane.open((frame) => {
|
|
236
|
+
created.last = String(frame);
|
|
237
|
+
for (const client of created.clients) paneWrite(client, 'frame', created.last);
|
|
238
|
+
})).then((handle) => {
|
|
239
|
+
if (panes.get(session.id) === created) { created.handle = handle; return; }
|
|
240
|
+
try { handle.close(); } catch { /* everyone left before the open resolved */ }
|
|
241
|
+
}).catch((error: unknown) => {
|
|
242
|
+
if (panes.get(session.id) !== created) return;
|
|
243
|
+
panes.delete(session.id);
|
|
244
|
+
for (const client of created.clients) { paneWrite(client, 'no-pane', { reason: `open failed: ${error instanceof Error ? error.message : String(error)}` }); client.end(); }
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const joined = hub;
|
|
248
|
+
joined.clients.add(response);
|
|
249
|
+
// Immediate paint: late joiners get the last frame, the first subscriber a one-shot snapshot.
|
|
250
|
+
if (joined.last != null) paneWrite(response, 'frame', joined.last);
|
|
251
|
+
else pane.snapshot().then((snap) => { if (joined.last == null && joined.clients.has(response) && snap) paneWrite(response, 'frame', snap); }, () => {});
|
|
252
|
+
request.on('close', () => {
|
|
253
|
+
joined.clients.delete(response);
|
|
254
|
+
if (joined.clients.size) return;
|
|
255
|
+
panes.delete(session.id);
|
|
256
|
+
console.log(`[pane] closePane ${session.id}`);
|
|
257
|
+
try { joined.handle?.close(); } catch { /* already gone */ }
|
|
258
|
+
});
|
|
121
259
|
}
|
|
122
260
|
|
|
123
261
|
async function handleApi(
|
|
@@ -125,24 +263,102 @@ async function handleApi(
|
|
|
125
263
|
response: import('node:http').ServerResponse,
|
|
126
264
|
url: URL,
|
|
127
265
|
sessions: SessionManager,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
266
|
+
createBackend: CreateBackend,
|
|
267
|
+
app: AppBackend,
|
|
268
|
+
chat: OrdinaryChat,
|
|
269
|
+
builder: BuilderFlag,
|
|
131
270
|
): Promise<void> {
|
|
271
|
+
const reloadServer = app.reload;
|
|
272
|
+
const { accounts } = app;
|
|
273
|
+
const mutationAllowed = app.mutationAllowed;
|
|
274
|
+
// With accounts, every build route needs a signed-in account allowed to build, and a
|
|
275
|
+
// conversation belongs to the account that started it. Ownerless (older) ones go to managers.
|
|
276
|
+
const principal = await app.app.resolvePrincipal(request);
|
|
277
|
+
// Ordinary chat never touches the build routes below: its conversations belong to one owner.
|
|
278
|
+
const chatOwner = chat.owner(request, principal);
|
|
279
|
+
// The app's chat rule: signed in (or a guest where guests are allowed), and holding one of
|
|
280
|
+
// `chat.roles` when the app names them. Builder mode is a separate permission below.
|
|
281
|
+
const chatRoles = app.config.chat?.roles;
|
|
282
|
+
const chats = (who: typeof principal) => !(accounts && !accounts.config.guests && who.kind === 'anonymous')
|
|
283
|
+
&& (!chatRoles || (who.kind === 'user' && who.roles.some((role) => chatRoles.includes(role))));
|
|
284
|
+
// Without accounts the app is the local single-person mode, where everything is open.
|
|
285
|
+
const mayChat = (Boolean(app.config.chat) || !accounts) && chats(principal);
|
|
286
|
+
const denied = (what: 'build' | 'chat') => json(response, principal.kind === 'anonymous' ? 401 : 403,
|
|
287
|
+
{ error: principal.kind === 'anonymous' ? `Sign in to ${what}.` : `Your account may not ${what === 'build' ? 'build this app' : 'chat in this app'}.` });
|
|
288
|
+
if (url.pathname === '/api/chat') {
|
|
289
|
+
if (request.method === 'GET') {
|
|
290
|
+
// Signed out with guests off, or a role the app does not let chat: this app offers you no
|
|
291
|
+
// chat, so the shell shows no chat toggle. Builder mode has its own permission below.
|
|
292
|
+
if (!mayChat) return json(response, 200, { provider: null, available: false });
|
|
293
|
+
const latest = chatOwner ? sessions.latest((session) => session.backend === 'anthropic' && session.owner === chatOwner) : undefined;
|
|
294
|
+
// `provider` is the app's rule for normal mode: null means no chat column outside builder mode.
|
|
295
|
+
const provider = app.config.chat?.provider ?? null;
|
|
296
|
+
return json(response, 200, { provider, agent: app.config.chat?.provider === 'tmux' ? app.config.chat.agent : undefined, available: provider === 'tmux' || chat.available, detail: provider === 'tmux' ? undefined : chat.detail, views: chat.views(), latest: latest && { id: latest.id, status: latest.status } });
|
|
297
|
+
}
|
|
298
|
+
if (request.method !== 'POST') return json(response, 404, { error: 'Unknown API route' });
|
|
299
|
+
if (!mayChat) return denied('chat');
|
|
300
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
301
|
+
if (!chat.available) return json(response, 503, { error: chat.detail });
|
|
302
|
+
const issued = chatOwner ? undefined : chat.issue();
|
|
303
|
+
const session = await sessions.start('anthropic', chat.backend(), false, chatOwner ?? issued!.owner);
|
|
304
|
+
if (issued) response.setHeader('Set-Cookie', issued.header);
|
|
305
|
+
return json(response, 201, { id: session.id, backend: session.backend, status: session.status });
|
|
306
|
+
}
|
|
307
|
+
const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)/);
|
|
308
|
+
// `golem say` from the agent's own tmux session: the reply to the user. Local-only and unauthenticated
|
|
309
|
+
// on purpose, the agent has no principal; the server binds to loopback.
|
|
310
|
+
if (request.method === 'POST' && sessionMatch && url.pathname === `/api/sessions/${sessionMatch[1]}/say`) {
|
|
311
|
+
const said = sessions.get(sessionMatch[1]);
|
|
312
|
+
if (!said || said.backend === 'anthropic') return json(response, 404, { error: 'Unknown session' });
|
|
313
|
+
const input = await body(request) as { text?: unknown };
|
|
314
|
+
if (typeof input.text !== 'string' || !input.text.trim()) return json(response, 400, { error: 'text must be a non-empty string' });
|
|
315
|
+
said.receive({ type: 'message', text: input.text });
|
|
316
|
+
await said.flush();
|
|
317
|
+
return json(response, 202, { status: said.status });
|
|
318
|
+
}
|
|
319
|
+
const mayBuild = !accounts || accounts.canBuild(principal);
|
|
320
|
+
// The Builder switch: whoever may build flips it; everyone else reads it as off.
|
|
321
|
+
if (url.pathname === '/api/builder') {
|
|
322
|
+
if (request.method === 'GET') return json(response, 200, { builder: mayBuild && builder.get() });
|
|
323
|
+
if (request.method !== 'POST') return json(response, 404, { error: 'Unknown API route' });
|
|
324
|
+
if (!mayBuild) return json(response, principal.kind === 'anonymous' ? 401 : 403, { error: 'Your account may not build this app.' });
|
|
325
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
326
|
+
const input = await body(request) as { builder?: unknown };
|
|
327
|
+
if (typeof input.builder !== 'boolean') return json(response, 400, { error: 'builder must be a boolean' });
|
|
328
|
+
await builder.set(input.builder);
|
|
329
|
+
if (!input.builder) await sessions.parkOthers(true); // leaving builder mode parks the builder agent
|
|
330
|
+
return json(response, 200, { builder: input.builder });
|
|
331
|
+
}
|
|
332
|
+
// The session routes below serve build mode and normal-mode chat alike, so each needs the rights
|
|
333
|
+
// of the mode it belongs to: a conversation carries its own `buildMode`, `/api/sessions/latest`
|
|
334
|
+
// says which it wants, and `POST /api/sessions` is judged below once its intent is parsed.
|
|
335
|
+
// `/api/runtime` is which agents this computer has; a terminal-agent chat needs it to pick one too.
|
|
336
|
+
const targetSession = sessionMatch ? sessions.get(sessionMatch[1]) : undefined;
|
|
337
|
+
const needs: 'chat' | 'build' | 'either' | undefined = targetSession ? (targetSession.buildMode ? 'build' : 'chat')
|
|
338
|
+
: url.pathname === '/api/runtime' ? (app.config.chat?.provider === 'tmux' ? 'either' : 'build')
|
|
339
|
+
: url.pathname === '/api/sessions/latest' ? (url.searchParams.get('chat') === '1' ? 'chat' : 'build')
|
|
340
|
+
: url.pathname === '/api/sessions' ? undefined
|
|
341
|
+
: 'build';
|
|
342
|
+
if (needs && !(needs === 'chat' ? mayChat : needs === 'build' ? mayBuild : mayBuild || mayChat)) return denied(needs === 'chat' ? 'chat' : 'build');
|
|
343
|
+
const owner = accounts && principal.kind === 'user' ? principal.id : undefined;
|
|
344
|
+
const visible = (session: Session) => session.backend === 'anthropic'
|
|
345
|
+
? mayChat && chatOwner !== null && session.owner === chatOwner
|
|
346
|
+
: !accounts || session.owner === owner || (!session.owner && accounts.manages(principal));
|
|
132
347
|
if (request.method === 'GET' && url.pathname === '/api/runtime') {
|
|
133
348
|
const discoveries = await discoverAgents();
|
|
134
|
-
json(response, 200, { discoveries, state: runtimeState(discoveries) });
|
|
349
|
+
json(response, 200, { discoveries, state: runtimeState(discoveries), builder: app.config.agents?.builder });
|
|
135
350
|
return;
|
|
136
351
|
}
|
|
137
352
|
if (request.method === 'POST' && url.pathname === '/api/sessions') {
|
|
138
|
-
if (!mutationAllowed(request
|
|
353
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
139
354
|
try {
|
|
140
355
|
const input = await body(request) as { backend?: string; intent?: string };
|
|
141
|
-
if (input.backend !== 'codex') return json(response, 400, { error: '
|
|
142
|
-
//
|
|
143
|
-
// danger-full-access, not app-root-confined — see CodexBackend's doc comment for why.
|
|
356
|
+
if (input.backend !== 'codex' && input.backend !== 'claude') return json(response, 400, { error: 'backend must be claude or codex' });
|
|
357
|
+
// Every agent session runs with the CLI's own bypass flags in the app root, the way Bridge Commander runs its workers.
|
|
144
358
|
const buildMode = input.intent === 'build';
|
|
145
|
-
|
|
359
|
+
if (!(buildMode ? mayBuild : mayChat)) return denied(buildMode ? 'build' : 'chat');
|
|
360
|
+
await sessions.parkOthers(buildMode); // one agent per window: the new one takes it
|
|
361
|
+
const session = await sessions.start(input.backend, await createBackend(input.backend, undefined, buildMode), buildMode, owner);
|
|
146
362
|
json(response, 201, { id: session.id, backend: session.backend, status: session.status });
|
|
147
363
|
} catch (error) {
|
|
148
364
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -150,10 +366,61 @@ async function handleApi(
|
|
|
150
366
|
}
|
|
151
367
|
return;
|
|
152
368
|
}
|
|
153
|
-
|
|
369
|
+
if (request.method === 'GET' && url.pathname === '/api/sessions/latest') {
|
|
370
|
+
// `?chat=1`: the latest normal-mode terminal-agent conversation instead of the latest build one.
|
|
371
|
+
const wantChat = url.searchParams.get('chat') === '1';
|
|
372
|
+
const latest = sessions.latest((session) => session.backend !== 'anthropic' && session.buildMode === !wantChat && visible(session));
|
|
373
|
+
if (!latest) return json(response, 404, { error: 'No saved build conversation' });
|
|
374
|
+
json(response, 200, { id: latest.id, backend: latest.backend, status: latest.status });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// The Terminal popup: the agent's live screen (SSE frames) and raw keystrokes into it.
|
|
378
|
+
const paneMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/pane\/(stream|input)$/);
|
|
379
|
+
if (paneMatch) {
|
|
380
|
+
const target = sessions.get(paneMatch[1]);
|
|
381
|
+
if (!target || !visible(target)) return json(response, 404, { error: 'Unknown session' });
|
|
382
|
+
if (request.method === 'GET' && paneMatch[2] === 'stream') return paneStream(request, response, target);
|
|
383
|
+
if (request.method === 'POST' && paneMatch[2] === 'input') {
|
|
384
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
385
|
+
const pane = target.worker.pane?.();
|
|
386
|
+
if (!target.worker.pane) return json(response, 501, { error: 'this backend cannot take pane input' });
|
|
387
|
+
if (!pane) return json(response, 404, { error: 'the agent has not started yet' });
|
|
388
|
+
const input = await body(request) as { key?: unknown; text?: unknown };
|
|
389
|
+
// Validation (key XOR text, tmux key grammar, size cap) is the harness's validatePaneInput; a refusal is a 502 like BC.
|
|
390
|
+
try { await pane.input({ key: input.key as string | undefined, text: input.text as string | undefined }); }
|
|
391
|
+
catch (error) { return json(response, 502, { error: error instanceof Error ? error.message : String(error) }); }
|
|
392
|
+
return json(response, 200, { ok: true });
|
|
393
|
+
}
|
|
394
|
+
return json(response, 404, { error: 'Unknown API route' });
|
|
395
|
+
}
|
|
396
|
+
const match = url.pathname.match(/^\/api\/sessions\/([^/]+)(?:\/(history|events|interrupt|commands|command))?$/);
|
|
154
397
|
if (!match) return json(response, 404, { error: 'Unknown API route' });
|
|
155
398
|
const session = sessions.get(match[1]);
|
|
156
|
-
if (!session) return json(response, 404, { error: 'Unknown session' });
|
|
399
|
+
if (!session || !visible(session)) return json(response, 404, { error: 'Unknown session' });
|
|
400
|
+
// Slash commands: `/reset` is the server's, the rest are the harness's own (`/status`, `/compact`, …).
|
|
401
|
+
if (request.method === 'GET' && match[2] === 'commands') {
|
|
402
|
+
return json(response, 200, { commands: [{ name: '/reset', description: 'park this conversation and start a fresh one with the same agent' }, ...(session.worker.commands?.() ?? [])] });
|
|
403
|
+
}
|
|
404
|
+
if (request.method === 'POST' && match[2] === 'command') {
|
|
405
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
406
|
+
const input = await body(request) as { line?: unknown };
|
|
407
|
+
const line = typeof input.line === 'string' ? input.line.trim() : '';
|
|
408
|
+
if (!line.startsWith('/')) return json(response, 400, { error: 'line must be a /command' });
|
|
409
|
+
try {
|
|
410
|
+
if (line.split(/\s+/)[0] === '/reset') {
|
|
411
|
+
// Same backend, same owner, same window: the old conversation is parked (resumable), the new agent takes the window.
|
|
412
|
+
await session.park();
|
|
413
|
+
const fresh = session.backend === 'anthropic'
|
|
414
|
+
? await sessions.start('anthropic', chat.backend(), false, session.owner)
|
|
415
|
+
: (await sessions.parkOthers(session.buildMode), await sessions.start(session.backend, await createBackend(session.backend, undefined, session.buildMode), session.buildMode, session.owner));
|
|
416
|
+
return json(response, 200, { text: `New conversation ${fresh.id.slice(0, 8)} started${session.backend === 'anthropic' ? '' : ' in the same terminal window'}; the previous one is parked.`, session: fresh.id });
|
|
417
|
+
}
|
|
418
|
+
if (!session.worker.runCommand) throw new Error(`unknown command ${line.split(/\s+/)[0]}`);
|
|
419
|
+
return json(response, 200, { text: await session.worker.runCommand(line) });
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return json(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
157
424
|
if (request.method === 'GET' && match[2] === 'history') {
|
|
158
425
|
json(response, 200, { events: session.history, status: session.status, backend: session.backend });
|
|
159
426
|
return;
|
|
@@ -161,22 +428,52 @@ async function handleApi(
|
|
|
161
428
|
if (request.method === 'GET' && match[2] === 'events') {
|
|
162
429
|
const after = Number(url.searchParams.get('after') ?? request.headers['last-event-id'] ?? '-1');
|
|
163
430
|
if (!Number.isInteger(after)) return json(response, 400, { error: 'after must be an integer sequence' });
|
|
431
|
+
// A chat tab's source view rides on this stream (browsers allow few connections per host):
|
|
432
|
+
// it opens with the tab's id as a `view` event, and closes with the stream.
|
|
433
|
+
const view = session.backend === 'anthropic' && url.searchParams.get('view') === '1' && chat.views()
|
|
434
|
+
? await app.app.views.open(request, principal, session.id) : undefined;
|
|
435
|
+
const closeView = view && await app.app.views.connect(request, principal, view.id, (event) => response.write(`event: view\ndata: ${JSON.stringify(event)}\n\n`));
|
|
164
436
|
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
165
437
|
response.flushHeaders();
|
|
438
|
+
if (view) response.write(`event: view\ndata: ${JSON.stringify({ type: 'view', id: view.id })}\n\n`);
|
|
439
|
+
request.on('close', () => closeView?.());
|
|
166
440
|
const write = (event: { sequence: number }) => response.write(`id: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
167
441
|
const unsubscribe = session.subscribeFrom(after, write);
|
|
168
|
-
request
|
|
442
|
+
// Re-resolve this same request when its account changes; a reader who may no longer build loses the stream.
|
|
443
|
+
const recheck = (accountId: string) => {
|
|
444
|
+
if (accountId !== owner) return;
|
|
445
|
+
void app.app.resolvePrincipal(request).then((now) => {
|
|
446
|
+
const allowed = session.backend === 'anthropic' ? chats(now) && now.kind === 'user' && now.id === session.owner
|
|
447
|
+
: session.buildMode ? accounts!.canBuild(now) : chats(now);
|
|
448
|
+
if (!allowed) response.end();
|
|
449
|
+
}, () => response.end());
|
|
450
|
+
};
|
|
451
|
+
accounts?.changes.on('change', recheck);
|
|
452
|
+
request.on('close', () => { unsubscribe(); accounts?.changes.off('change', recheck); });
|
|
169
453
|
return;
|
|
170
454
|
}
|
|
171
455
|
if (request.method === 'POST' && !match[2]) {
|
|
172
|
-
if (!mutationAllowed(request
|
|
456
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
173
457
|
try {
|
|
174
|
-
const input = await body(request) as { text?: unknown };
|
|
458
|
+
const input = await body(request) as { text?: unknown; clientMessageId?: unknown; attachments?: unknown; view?: unknown };
|
|
175
459
|
if (typeof input.text !== 'string' || !input.text.trim()) return json(response, 400, { error: 'text must be a non-empty string' });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
json(response,
|
|
179
|
-
|
|
460
|
+
if (typeof input.clientMessageId !== 'string' || !input.clientMessageId) return json(response, 400, { error: 'clientMessageId is required' });
|
|
461
|
+
const attachments = Array.isArray(input.attachments) && input.attachments.every((item) => item && typeof item.id === 'string' && typeof item.name === 'string' && (item.size === undefined || typeof item.size === 'number')) ? input.attachments : undefined;
|
|
462
|
+
if (input.attachments !== undefined && !attachments) return json(response, 400, { error: 'attachments must contain a name and id' });
|
|
463
|
+
// A chat message carries its sender, fixed here from this request; the turn acts as them.
|
|
464
|
+
const context = session.backend === 'anthropic' ? { principal, owner: chatOwner!, conversation: session.id, ...(input.view === undefined ? {} : { view: input.view as string }) } : undefined;
|
|
465
|
+
// A view names the tab that sent this message; it must be that person's view of this chat.
|
|
466
|
+
if (input.view !== undefined && !(session.backend === 'anthropic' && typeof input.view === 'string' && await app.app.views.bound(input.view, context!))) {
|
|
467
|
+
return json(response, 400, { error: 'That view does not belong to this conversation.' });
|
|
468
|
+
}
|
|
469
|
+
// A parked conversation takes the app's tmux session back before its agent resumes there.
|
|
470
|
+
if (session.backend !== 'anthropic' && !session.live) await sessions.parkOthers(session.buildMode, session.id);
|
|
471
|
+
const accepted = await session.accept(input.text, input.clientMessageId, attachments, context);
|
|
472
|
+
json(response, 202, { status: session.status, duplicate: accepted.duplicate });
|
|
473
|
+
if (!accepted.duplicate && accepted.completion) void accepted.completion.then(
|
|
474
|
+
() => { if (session.buildMode) triggerRebuild(session, reloadServer); },
|
|
475
|
+
() => {},
|
|
476
|
+
);
|
|
180
477
|
} catch (error) {
|
|
181
478
|
const message = error instanceof Error ? error.message : String(error);
|
|
182
479
|
json(response, message.startsWith('Request body') ? 400 : 409, { error: message, status: session.status });
|
|
@@ -184,7 +481,7 @@ async function handleApi(
|
|
|
184
481
|
return;
|
|
185
482
|
}
|
|
186
483
|
if (request.method === 'POST' && match[2] === 'interrupt') {
|
|
187
|
-
if (!mutationAllowed(request
|
|
484
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
188
485
|
await session.interrupt();
|
|
189
486
|
await session.flush();
|
|
190
487
|
json(response, 200, { status: session.status });
|
package/src/entry.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
7
|
+
const source = process.env.GOLEM_SOURCE && resolve(process.env.GOLEM_SOURCE)
|
|
8
|
+
const cli = source ? resolve(source, 'src/cli.ts') : resolve(frameworkRoot, 'src/cli.ts')
|
|
9
|
+
if (!existsSync(cli)) throw new Error(`GOLEM_SOURCE must point to a Golem checkout containing src/cli.ts: ${source}`)
|
|
10
|
+
|
|
11
|
+
const installed = !source && frameworkRoot.includes('/node_modules/')
|
|
12
|
+
const command = installed ? resolve(process.cwd(), 'node_modules/.bin/golem-kit') : process.execPath
|
|
13
|
+
const args = installed ? process.argv.slice(2) : [cli, ...process.argv.slice(2)]
|
|
14
|
+
const env = installed ? { ...process.env, PATH: `${resolve(process.cwd(), 'node_modules/.bin')}:${process.env.PATH ?? ''}` } : process.env
|
|
15
|
+
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: 'inherit' })
|
|
16
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => child.kill(signal))
|
|
17
|
+
const result = await new Promise((resolve, reject) => child.once('error', reject).once('exit', (code, signal) => resolve({ code, signal })))
|
|
18
|
+
if (result.signal) process.kill(process.pid, result.signal)
|
|
19
|
+
process.exitCode = result.code ?? 1
|
package/src/eslint.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import babelParser from '@babel/eslint-parser'
|
|
2
|
+
|
|
3
|
+
// Storage and database clients. Extend with `drivers: [...golemDrivers, 'my-driver']`.
|
|
4
|
+
export const golemDrivers = [
|
|
5
|
+
'pg', 'postgres', 'mysql', 'mysql2', 'sqlite', 'sqlite3', 'better-sqlite3', 'node:sqlite', '@libsql/client',
|
|
6
|
+
'mongodb', 'mongoose', 'redis', 'ioredis', '@prisma/client', 'drizzle-orm', 'knex', 'kysely', 'typeorm', 'sequelize',
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
// Shared architectural lint for Golem apps. Pass `false` for a boundary to disable it.
|
|
10
|
+
export default function golem({ server = 'src/server', persistence = 'src/server/persistence', drivers = golemDrivers, serverModules = ['golem-kit/server'] } = {}) {
|
|
11
|
+
const driverRule = drivers ? [{
|
|
12
|
+
name: drivers,
|
|
13
|
+
message: `Storage drivers belong in persistence adapters${persistence ? ` under ${persistence}/` : ''}.`,
|
|
14
|
+
}] : []
|
|
15
|
+
const serverRule = server ? [{
|
|
16
|
+
name: serverModules,
|
|
17
|
+
message: `Server-only modules belong under ${server}/.`,
|
|
18
|
+
}, {
|
|
19
|
+
regex: '^node:',
|
|
20
|
+
message: `Node built-ins are server-only; use them under ${server}/.`,
|
|
21
|
+
}, {
|
|
22
|
+
// Matches relative specifiers by directory name, not resolved paths; aliases are not checked.
|
|
23
|
+
regex: `^\\.\\.?/(.*/)?${escape(server.split('/').pop())}(/|$)`,
|
|
24
|
+
message: `Browser and domain code cannot import backend modules from ${server}/.`,
|
|
25
|
+
}] : []
|
|
26
|
+
const restrict = (rules) => {
|
|
27
|
+
const patterns = rules.filter((rule) => rule.regex || rule.name?.length).map(({ name, ...rule }) => name ? { group: name.map(exact), ...rule } : rule)
|
|
28
|
+
return { 'no-restricted-imports': patterns.length ? ['error', { patterns }] : 'off' }
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
{ name: 'golem/ignores', ignores: ['dist/', '.golem/'] },
|
|
32
|
+
parser('golem/typescript', ['**/*.{ts,mts,cts}'], ['typescript']),
|
|
33
|
+
parser('golem/tsx', ['**/*.{js,mjs,jsx,tsx}'], ['typescript', 'jsx']),
|
|
34
|
+
{ name: 'golem/boundaries', files: ['src/**'], rules: restrict([...driverRule, ...serverRule]) },
|
|
35
|
+
...(server ? [{ name: 'golem/server', files: [`${server}/**`], rules: restrict(driverRule) }] : []),
|
|
36
|
+
...(persistence ? [{ name: 'golem/persistence', files: [`${persistence}/**`], rules: restrict([]) }] : []),
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Babel parses TypeScript syntax without type checking; the TypeScript compiler still owns types.
|
|
41
|
+
function parser(name, files, plugins) {
|
|
42
|
+
return {
|
|
43
|
+
name, files,
|
|
44
|
+
languageOptions: { parser: babelParser, parserOptions: { requireConfigFile: false, babelOptions: { babelrc: false, configFile: false, parserOpts: { plugins } } } },
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function escape(value) {
|
|
49
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// gitignore-style group entries: match the package root and its subpaths exactly.
|
|
53
|
+
function exact(name) {
|
|
54
|
+
return `/${name.replace(/[*?[\]!]/g, '\\$&')}`
|
|
55
|
+
}
|