golem-kit 0.1.0 → 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 +11 -6
- 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 +31 -15
- 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 +91 -17
- package/src/client.ts +205 -0
- package/src/config.ts +169 -0
- package/src/dev-server.ts +339 -38
- 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,14 +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 {
|
|
7
|
+
import { createAppBackend, type AppBackend } from './backend/http.ts';
|
|
8
|
+
import { ordinaryChat, type OrdinaryChat } from './chat.ts';
|
|
9
|
+
import { openBrain } from './brain.ts';
|
|
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';
|
|
9
14
|
import { ConversationState } from './runtime/state.ts';
|
|
10
15
|
|
|
11
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
|
+
}
|
|
12
29
|
const root = pathToFileURL(`${process.cwd()}/dist/`);
|
|
13
30
|
const types: Record<string, string> = {
|
|
14
31
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -20,23 +37,64 @@ const types: Record<string, string> = {
|
|
|
20
37
|
// The CLI owns logging and signals; this server only serves the built browser shell.
|
|
21
38
|
export async function startDevServer(
|
|
22
39
|
port = 3000,
|
|
23
|
-
createBackend
|
|
40
|
+
createBackend?: CreateBackend,
|
|
24
41
|
stateDirectory = join(appRoot, '.golem'),
|
|
42
|
+
host = '127.0.0.1',
|
|
25
43
|
): Promise<Server> {
|
|
26
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);
|
|
27
51
|
const state = new ConversationState(stateDirectory);
|
|
28
52
|
const sessions = new SessionManager((snapshots) => state.save(snapshots));
|
|
29
|
-
|
|
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
|
+
}
|
|
30
88
|
const server = createServer((request, response) => {
|
|
31
|
-
void handleRequest(request, response, sessions, port, 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) => {
|
|
32
90
|
if (!response.headersSent) json(response, 400, { error: error instanceof Error ? error.message : 'Malformed request' });
|
|
33
91
|
else response.destroy();
|
|
34
92
|
});
|
|
35
93
|
});
|
|
36
|
-
server.once('close', () => { void sessions.disposeAll().then(() => sessions.flushAll()) });
|
|
94
|
+
server.once('close', () => { void sessions.disposeAll().then(() => sessions.flushAll()).finally(() => app.close()) });
|
|
37
95
|
return new Promise((resolve, reject) => {
|
|
38
96
|
server.once('error', reject);
|
|
39
|
-
server.listen(port,
|
|
97
|
+
server.listen(port, host, () => resolve(server));
|
|
40
98
|
});
|
|
41
99
|
}
|
|
42
100
|
|
|
@@ -45,12 +103,16 @@ async function handleRequest(
|
|
|
45
103
|
response: import('node:http').ServerResponse,
|
|
46
104
|
sessions: SessionManager,
|
|
47
105
|
port: number,
|
|
48
|
-
|
|
106
|
+
host: string,
|
|
107
|
+
createBackend: CreateBackend,
|
|
108
|
+
app: AppBackend,
|
|
109
|
+
chat: OrdinaryChat,
|
|
110
|
+
builder: BuilderFlag,
|
|
49
111
|
): Promise<void> {
|
|
50
112
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
51
113
|
decodeURIComponent(url.pathname);
|
|
52
114
|
if (url.pathname.startsWith('/api/')) {
|
|
53
|
-
await handleApi(request, response, url, sessions,
|
|
115
|
+
await handleApi(request, response, url, sessions, createBackend, app, chat, builder);
|
|
54
116
|
return;
|
|
55
117
|
}
|
|
56
118
|
let pathname: string;
|
|
@@ -95,6 +157,29 @@ function json(response: import('node:http').ServerResponse, status: number, body
|
|
|
95
157
|
response.end(JSON.stringify(body));
|
|
96
158
|
}
|
|
97
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
|
+
|
|
98
183
|
async function body(request: import('node:http').IncomingMessage): Promise<unknown> {
|
|
99
184
|
let text = '';
|
|
100
185
|
for await (const chunk of request) {
|
|
@@ -104,17 +189,73 @@ async function body(request: import('node:http').IncomingMessage): Promise<unkno
|
|
|
104
189
|
try { return JSON.parse(text || '{}'); } catch { throw new Error('Request body must be valid JSON'); }
|
|
105
190
|
}
|
|
106
191
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
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)));
|
|
113
204
|
}
|
|
114
205
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
});
|
|
118
259
|
}
|
|
119
260
|
|
|
120
261
|
async function handleApi(
|
|
@@ -122,23 +263,102 @@ async function handleApi(
|
|
|
122
263
|
response: import('node:http').ServerResponse,
|
|
123
264
|
url: URL,
|
|
124
265
|
sessions: SessionManager,
|
|
125
|
-
|
|
126
|
-
|
|
266
|
+
createBackend: CreateBackend,
|
|
267
|
+
app: AppBackend,
|
|
268
|
+
chat: OrdinaryChat,
|
|
269
|
+
builder: BuilderFlag,
|
|
127
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));
|
|
128
347
|
if (request.method === 'GET' && url.pathname === '/api/runtime') {
|
|
129
348
|
const discoveries = await discoverAgents();
|
|
130
|
-
json(response, 200, { discoveries, state: runtimeState(discoveries) });
|
|
349
|
+
json(response, 200, { discoveries, state: runtimeState(discoveries), builder: app.config.agents?.builder });
|
|
131
350
|
return;
|
|
132
351
|
}
|
|
133
352
|
if (request.method === 'POST' && url.pathname === '/api/sessions') {
|
|
134
|
-
if (!mutationAllowed(request
|
|
353
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
135
354
|
try {
|
|
136
355
|
const input = await body(request) as { backend?: string; intent?: string };
|
|
137
|
-
if (input.backend !== 'codex') return json(response, 400, { error: '
|
|
138
|
-
//
|
|
139
|
-
// 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.
|
|
140
358
|
const buildMode = input.intent === 'build';
|
|
141
|
-
|
|
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);
|
|
142
362
|
json(response, 201, { id: session.id, backend: session.backend, status: session.status });
|
|
143
363
|
} catch (error) {
|
|
144
364
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -146,10 +366,61 @@ async function handleApi(
|
|
|
146
366
|
}
|
|
147
367
|
return;
|
|
148
368
|
}
|
|
149
|
-
|
|
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))?$/);
|
|
150
397
|
if (!match) return json(response, 404, { error: 'Unknown API route' });
|
|
151
398
|
const session = sessions.get(match[1]);
|
|
152
|
-
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
|
+
}
|
|
153
424
|
if (request.method === 'GET' && match[2] === 'history') {
|
|
154
425
|
json(response, 200, { events: session.history, status: session.status, backend: session.backend });
|
|
155
426
|
return;
|
|
@@ -157,22 +428,52 @@ async function handleApi(
|
|
|
157
428
|
if (request.method === 'GET' && match[2] === 'events') {
|
|
158
429
|
const after = Number(url.searchParams.get('after') ?? request.headers['last-event-id'] ?? '-1');
|
|
159
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`));
|
|
160
436
|
response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
161
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?.());
|
|
162
440
|
const write = (event: { sequence: number }) => response.write(`id: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
163
441
|
const unsubscribe = session.subscribeFrom(after, write);
|
|
164
|
-
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); });
|
|
165
453
|
return;
|
|
166
454
|
}
|
|
167
455
|
if (request.method === 'POST' && !match[2]) {
|
|
168
|
-
if (!mutationAllowed(request
|
|
456
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
169
457
|
try {
|
|
170
|
-
const input = await body(request) as { text?: unknown };
|
|
458
|
+
const input = await body(request) as { text?: unknown; clientMessageId?: unknown; attachments?: unknown; view?: unknown };
|
|
171
459
|
if (typeof input.text !== 'string' || !input.text.trim()) return json(response, 400, { error: 'text must be a non-empty string' });
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
json(response,
|
|
175
|
-
|
|
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
|
+
);
|
|
176
477
|
} catch (error) {
|
|
177
478
|
const message = error instanceof Error ? error.message : String(error);
|
|
178
479
|
json(response, message.startsWith('Request body') ? 400 : 409, { error: message, status: session.status });
|
|
@@ -180,7 +481,7 @@ async function handleApi(
|
|
|
180
481
|
return;
|
|
181
482
|
}
|
|
182
483
|
if (request.method === 'POST' && match[2] === 'interrupt') {
|
|
183
|
-
if (!mutationAllowed(request
|
|
484
|
+
if (!mutationAllowed(request)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
|
|
184
485
|
await session.interrupt();
|
|
185
486
|
await session.flush();
|
|
186
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
|
+
}
|