golem-kit 0.1.1 → 0.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +261 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +22 -12
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +25 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +103 -14
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  41. package/src/entry.mjs +23 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. 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 { discoverAgents, runtimeState } from './runtime/discovery.ts';
7
- import { CodexBackend, type SandboxMode } from './runtime/codex.ts';
8
- import { SessionManager, type Session, type SessionBackend } from './runtime/session.ts';
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: (mode: SandboxMode, threadId?: string) => SessionBackend = (mode, threadId) => new CodexBackend(appRoot, mode, 'codex', [], threadId),
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
- sessions.restore(await state.load(), (snapshot) => createBackend(snapshot.buildMode ? 'danger-full-access' : 'read-only', snapshot.threadId));
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: (mode: SandboxMode, threadId?: string) => SessionBackend,
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, port, host, createBackend);
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
- /** Fires once after a successful build-mode turn; never blocks the turn's own response. */
111
- function triggerRebuild(session: Session): void {
112
- void rebuild().then(
113
- () => session.notifyRebuilt(),
114
- (error) => session.notifyBuildFailed(error instanceof Error ? error.message : String(error)),
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
- function mutationAllowed(request: import('node:http').IncomingMessage, port: number, host: string): boolean {
119
- const origin = request.headers.origin;
120
- return !origin || origin === serverUrl(host, port).slice(0, -1) || (host === '127.0.0.1' && origin === `http://localhost:${port}`);
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
- port: number,
129
- host: string,
130
- createBackend: (mode: SandboxMode) => SessionBackend,
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, port, host)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
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: 'Only the connected Codex backend can start a session' });
142
- // Server-owned: only an explicit build intent grants filesystem access. Omitted intent stays read-only.
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
- const session = await sessions.start('codex', createBackend(buildMode ? 'danger-full-access' : 'read-only'), buildMode);
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
- const match = url.pathname.match(/^\/api\/sessions\/([^/]+)(?:\/(history|events|interrupt))?$/);
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.on('close', unsubscribe);
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, port, host)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
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
- await session.send(input.text);
177
- await session.flush();
178
- json(response, 202, { status: session.status });
179
- if (session.buildMode) triggerRebuild(session);
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, port, host)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
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,23 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
8
+ const source = process.env.GOLEM_SOURCE && resolve(process.env.GOLEM_SOURCE)
9
+ const cli = source ? resolve(source, 'src/cli.ts') : resolve(frameworkRoot, 'src/cli.ts')
10
+ if (!existsSync(cli)) throw new Error(`GOLEM_SOURCE must point to a Golem checkout containing src/cli.ts: ${source}`)
11
+
12
+ // Node does not strip types from files under node_modules, so the installed CLI needs tsx. Resolve
13
+ // it from this file — golem-kit's own dependency tree — because pnpm does not link a transitive
14
+ // dependency's bin into the app's node_modules/.bin, so the app's PATH may not have tsx at all.
15
+ const installed = !source && frameworkRoot.includes('/node_modules/')
16
+ const args = installed
17
+ ? [createRequire(import.meta.url).resolve('tsx/cli'), cli, ...process.argv.slice(2)]
18
+ : [cli, ...process.argv.slice(2)]
19
+ const child = spawn(process.execPath, args, { cwd: process.cwd(), stdio: 'inherit' })
20
+ for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => child.kill(signal))
21
+ const result = await new Promise((resolve, reject) => child.once('error', reject).once('exit', (code, signal) => resolve({ code, signal })))
22
+ if (result.signal) process.kill(process.pid, result.signal)
23
+ 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
+ }