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.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +259 -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 +19 -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 +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +85 -13
  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 +19 -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
@@ -1,18 +1,44 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import type { AgentName } from './discovery.ts'
3
+ import type { TurnContext } from './assistant.ts'
4
+ import { citations } from '../brain.ts'
5
+
6
+ /** A terminal agent, or `anthropic`: the ordinary-use API agent. */
7
+ export type BackendName = AgentName | 'anthropic'
3
8
 
4
9
  export type BackendEvent =
5
10
  | { type: 'message'; text: string }
6
11
  | { type: 'interrupted'; reason?: string }
7
12
  | { type: 'error'; message: string }
13
+ | { type: 'tool'; name: string; ok: boolean; text?: string }
8
14
 
9
- /** The runtime contract only; Claude/Codex process bridging is intentionally not implemented yet. */
10
15
  export type SessionBackend = {
11
- start(emit: (event: BackendEvent) => void): Promise<void>
12
- send(text: string): Promise<void>
16
+ /** `sessionId` names the conversation this backend serves (the tmux backend's session name and `GOLEM_SESSION`). */
17
+ start(emit: (event: BackendEvent) => void, sessionId: string): Promise<void>
18
+ /** `context` is the sender of this message, when the server accepted it from a person. */
19
+ send(text: string, context?: TurnContext): Promise<void>
13
20
  shutdown(): Promise<void>
14
21
  interrupt?(): Promise<void>
15
- threadId?(): string | undefined
22
+ /** Called instead of `shutdown` when the server stops: a backend that outlives the server keeps running. */
23
+ detach?(): Promise<void>
24
+ /** Where to find or resume the agent after a server restart; saved with the conversation. */
25
+ harnessRef?(): unknown
26
+ /** Conversation state a backend keeps itself, saved with the conversation and handed back on restore. */
27
+ transcript?(): unknown
28
+ /** The agent's live terminal screen, when the backend has one to show (the tmux backend). */
29
+ pane?(): PaneAccess | undefined
30
+ /** Slash commands the harness honours, and their runner; the reply is the strip's system text. */
31
+ commands?(): SlashCommand[]
32
+ runCommand?(line: string): Promise<string>
33
+ }
34
+
35
+ export type SlashCommand = { name: string; description: string; args?: Array<{ value: string; description: string }> }
36
+
37
+ /** One agent screen: `open` streams whole-screen frames on change until closed, `input` types one key or a literal string. */
38
+ export type PaneAccess = {
39
+ open(onFrame: (frame: string) => void): Promise<{ close(): void }> | { close(): void }
40
+ snapshot(): Promise<string>
41
+ input(input: { key?: string; text?: string }): Promise<void>
16
42
  }
17
43
 
18
44
  export type SessionStatus = 'starting' | 'ready' | 'interrupted' | 'stopped' | 'failed'
@@ -20,10 +46,17 @@ export type SessionStatus = 'starting' | 'ready' | 'interrupted' | 'stopped' | '
20
46
  export type SessionEvent = {
21
47
  sequence: number
22
48
  sessionId: string
23
- type: 'status' | 'user' | 'message' | 'interrupted' | 'error' | 'rebuilt'
49
+ type: 'status' | 'user' | 'message' | 'interrupted' | 'error' | 'rebuilt' | 'tool'
24
50
  status?: SessionStatus
25
51
  text?: string
26
52
  reason?: string
53
+ /** A tool event's operation name and whether the call succeeded. */
54
+ name?: string
55
+ ok?: boolean
56
+ clientMessageId?: string
57
+ attachments?: Array<{ id: string; name: string; size?: number }>
58
+ /** Brain locations (`path#Lstart-Lend`) the agent cited in this message. */
59
+ sources?: string[]
27
60
  }
28
61
 
29
62
  export class Session {
@@ -31,31 +64,40 @@ export class Session {
31
64
  status: SessionStatus = 'starting'
32
65
  private readonly listeners = new Set<(event: SessionEvent) => void>()
33
66
  private sequence = 0
34
- private readonly pending: Array<{ text: string; resolve: () => void; reject: (error: Error) => void }> = []
67
+ private readonly pending: Array<{ text: string; context?: TurnContext; generation: number; resolve: () => void; reject: (error: Error) => void }> = []
35
68
  private active = false
36
69
  private dispatchScheduled = false
37
70
  private closed = false
38
71
  private shutdownPromise: Promise<void> | undefined
39
72
  private workerShutdownPromise: Promise<void> | undefined
40
73
  private activeReject: ((error: Error) => void) | undefined
74
+ /** The sender of the turn now running; revocation checks it. */
75
+ activeContext: TurnContext | undefined
41
76
  private workerStarted = false
42
77
  private persistence = Promise.resolve()
43
78
  private persistenceError: Error | undefined
79
+ private readonly pendingReceipts = new Map<string, { event: SessionEvent; receipt: Promise<void> }>()
80
+ private requestGeneration = 0
81
+ private updatedAt: string | undefined = new Date().toISOString()
44
82
  readonly id: string
45
- readonly backend: AgentName
83
+ readonly backend: BackendName
46
84
  /** Server-owned: set once at creation from the request's explicit build intent, never inferred. */
47
85
  readonly buildMode: boolean
48
- private readonly worker: SessionBackend
86
+ /** The account that started this conversation when the app has accounts; only it may use it. */
87
+ readonly owner: string | undefined
88
+ readonly worker: SessionBackend
49
89
  private readonly save?: (snapshot: SessionSnapshot) => Promise<void>
50
90
 
51
91
  constructor(
52
- backend: AgentName,
92
+ backend: BackendName,
53
93
  worker: SessionBackend,
54
94
  id: string,
55
95
  buildMode = false,
56
96
  save?: (snapshot: SessionSnapshot) => Promise<void>,
97
+ owner?: string,
57
98
  ) {
58
99
  this.id = id
100
+ this.owner = owner
59
101
  this.backend = backend
60
102
  this.worker = worker
61
103
  this.buildMode = buildMode
@@ -81,17 +123,19 @@ export class Session {
81
123
  }
82
124
 
83
125
  static restore(snapshot: SessionSnapshot, worker: SessionBackend, save?: (snapshot: SessionSnapshot) => Promise<void>): Session {
84
- const session = new Session(snapshot.backend, worker, snapshot.id, snapshot.buildMode, save)
126
+ const session = new Session(snapshot.backend, worker, snapshot.id, snapshot.buildMode, save, snapshot.owner)
85
127
  session.history.push(...snapshot.history)
86
128
  session.status = snapshot.status
87
129
  session.sequence = Math.max(-1, ...snapshot.history.map((event) => event.sequence)) + 1
88
- session.closed = snapshot.status === 'stopped'
130
+ session.updatedAt = snapshot.updatedAt
131
+ // A stopped session with a harness ref was parked, not closed: its next message resumes the agent.
132
+ session.closed = snapshot.status === 'stopped' && !snapshot.harness
89
133
  if (snapshot.active) session.recoverInterrupted()
90
134
  return session
91
135
  }
92
136
 
93
137
  snapshot(): SessionSnapshot {
94
- return { id: this.id, backend: this.backend, buildMode: this.buildMode, status: this.status, active: this.active, history: this.history, threadId: this.worker.threadId?.() }
138
+ return { id: this.id, backend: this.backend, buildMode: this.buildMode, ...(this.owner ? { owner: this.owner } : {}), status: this.status, active: this.active, history: [...this.history, ...[...this.pendingReceipts.values()].map(({ event }) => event)], harness: this.worker.harnessRef?.(), ...(this.worker.transcript ? { transcript: this.worker.transcript() } : {}), updatedAt: this.updatedAt }
95
139
  }
96
140
 
97
141
  async flush(): Promise<void> {
@@ -99,15 +143,28 @@ export class Session {
99
143
  if (this.persistenceError) throw this.persistenceError
100
144
  }
101
145
 
102
- send(text: string): Promise<void> {
103
- if (this.closed || (this.status !== 'ready' && this.status !== 'interrupted' && this.status !== 'failed')) {
104
- return Promise.reject(new Error(`Session ${this.status}`))
105
- }
106
- if (this.status === 'interrupted' || this.status === 'failed') this.setStatus('ready')
107
- return new Promise((resolve, reject) => {
108
- this.pending.push({ text, resolve, reject })
109
- this.pump()
110
- })
146
+ /** `context` stays with this message through the queue; a duplicate keeps the first one's. */
147
+ async accept(text: string, clientMessageId: string, attachments?: SessionEvent['attachments'], context?: TurnContext): Promise<{ duplicate: boolean; completion?: Promise<void> }> {
148
+ if (this.closed || this.status === 'starting') throw new Error(`Session ${this.status}`)
149
+ if (!clientMessageId) throw new Error('clientMessageId is required')
150
+ if (this.history.some((event) => event.type === 'user' && event.clientMessageId === clientMessageId)) return { duplicate: true }
151
+ const pending = this.pendingReceipts.get(clientMessageId)
152
+ if (pending) { await pending.receipt; return { duplicate: true } }
153
+ if (this.status !== 'ready') this.setStatus('ready')
154
+ const generation = this.requestGeneration
155
+ await this.recordDurably({ type: 'user', text, clientMessageId, attachments })
156
+ if (generation !== this.requestGeneration || this.closed || this.status !== 'ready') throw new Error(`Session ${this.status}`)
157
+ let resolve!: () => void
158
+ let reject!: (error: Error) => void
159
+ const completion = new Promise<void>((ok, fail) => { resolve = ok; reject = fail })
160
+ this.pending.push({ text, context, generation, resolve, reject })
161
+ this.pump()
162
+ return { duplicate: false, completion }
163
+ }
164
+
165
+ async send(text: string): Promise<void> {
166
+ const accepted = await this.accept(text, randomUUID())
167
+ await accepted.completion
111
168
  }
112
169
 
113
170
  subscribe(listener: (event: SessionEvent) => void): () => void {
@@ -123,16 +180,35 @@ export class Session {
123
180
  async shutdown(): Promise<void> {
124
181
  if (this.shutdownPromise) return this.shutdownPromise
125
182
  this.closed = true
183
+ this.requestGeneration++
126
184
  this.setStatus('stopped')
127
185
  this.rejectPending(new Error('Session stopped'))
128
186
  this.shutdownPromise = this.closeWorker()
129
187
  return this.shutdownPromise
130
188
  }
131
189
 
132
- async dispose(): Promise<void> { await this.closeWorker() }
190
+ /** The agent is running: a tmux session exists for this conversation. */
191
+ get live(): boolean { return this.workerStarted && !this.closed }
192
+
193
+ /**
194
+ * Kills the worker but keeps the conversation: history and the harness ref (with its resume id) stay
195
+ * in the snapshot, and the next message resumes the agent the way a server restart does.
196
+ */
197
+ async park(): Promise<void> {
198
+ if (!this.live) return
199
+ this.requestGeneration++
200
+ this.workerStarted = false
201
+ this.setStatus('stopped')
202
+ this.activeReject?.(new Error('Session parked'))
203
+ this.rejectPending(new Error('Session parked'))
204
+ await this.worker.shutdown()
205
+ }
206
+
207
+ async dispose(): Promise<void> { await (this.worker.detach ? this.worker.detach() : this.closeWorker()) }
133
208
 
134
209
  abandon(): void {
135
210
  if (!this.active || this.closed) return
211
+ this.requestGeneration++
136
212
  this.setStatus('interrupted')
137
213
  this.record({ type: 'interrupted', reason: 'server restarted during this turn' })
138
214
  this.activeReject?.(new Error('Session interrupted'))
@@ -141,6 +217,7 @@ export class Session {
141
217
  async interrupt(): Promise<void> {
142
218
  if (this.closed || this.status === 'stopped') return
143
219
  if (this.status !== 'ready') return
220
+ this.requestGeneration++
144
221
  this.setStatus('interrupted')
145
222
  this.record({ type: 'interrupted', reason: 'interrupted by user' })
146
223
  this.activeReject?.(new Error('Session interrupted'))
@@ -161,9 +238,11 @@ export class Session {
161
238
  this.record({ type: 'error', text: message })
162
239
  }
163
240
 
164
- private receive(event: BackendEvent): void {
241
+ /** Also the entry for replies the agent posts itself (`golem say`). */
242
+ receive(event: BackendEvent): void {
165
243
  if (this.closed) return
166
- if (event.type === 'message') this.record({ type: 'message', text: event.text })
244
+ if (event.type === 'message') { const sources = citations(event.text); this.record({ type: 'message', text: event.text, ...(sources.length ? { sources } : {}) }) }
245
+ if (event.type === 'tool') this.record({ type: 'tool', name: event.name, ok: event.ok, text: event.text })
167
246
  if (event.type === 'interrupted') {
168
247
  const alreadyInterrupted = this.status === 'interrupted'
169
248
  if (!alreadyInterrupted) this.setStatus('interrupted')
@@ -185,14 +264,20 @@ export class Session {
185
264
  Promise.resolve()
186
265
  .then(async () => {
187
266
  this.dispatchScheduled = false
188
- if (this.closed || this.status !== 'ready' || this.pending[0] !== next) {
267
+ if (next.generation !== this.requestGeneration || this.closed || this.status !== 'ready' || this.pending[0] !== next) {
189
268
  this.pump()
190
269
  return
191
270
  }
192
271
  this.pending.shift()
193
272
  this.active = true
194
- this.record({ type: 'user', text: next.text })
195
- if (this.closed || this.status !== 'ready') {
273
+ this.persist()
274
+ try { await this.flush() } catch (error) {
275
+ this.active = false
276
+ next.reject(error instanceof Error ? error : new Error(String(error)))
277
+ this.pump()
278
+ return
279
+ }
280
+ if (next.generation !== this.requestGeneration || this.closed || this.status !== 'ready') {
196
281
  this.active = false
197
282
  next.reject(new Error(`Session ${this.status}`))
198
283
  this.pump()
@@ -200,15 +285,17 @@ export class Session {
200
285
  }
201
286
  try {
202
287
  await this.startWorker()
203
- if (this.closed || this.status !== 'ready') {
288
+ if (next.generation !== this.requestGeneration || this.closed || this.status !== 'ready') {
204
289
  this.active = false
205
290
  next.reject(new Error(`Session ${this.status}`))
206
291
  this.pump()
207
292
  return
208
293
  }
209
294
  this.activeReject = next.reject
210
- Promise.resolve(this.worker.send(next.text)).then(next.resolve, next.reject).finally(() => {
295
+ this.activeContext = next.context
296
+ Promise.resolve(this.worker.send(next.text, next.context)).then(next.resolve, next.reject).finally(() => {
211
297
  this.activeReject = undefined
298
+ this.activeContext = undefined
212
299
  this.active = false
213
300
  this.persist()
214
301
  this.pump()
@@ -232,7 +319,7 @@ export class Session {
232
319
 
233
320
  private async startWorker(): Promise<boolean> {
234
321
  if (this.workerStarted) return false
235
- await this.worker.start((event) => this.receive(event))
322
+ await this.worker.start((event) => this.receive(event), this.id)
236
323
  this.workerStarted = true
237
324
  return true
238
325
  }
@@ -252,10 +339,34 @@ export class Session {
252
339
  private record(event: Omit<SessionEvent, 'sequence' | 'sessionId'>): void {
253
340
  const complete = { ...event, sequence: this.sequence++, sessionId: this.id }
254
341
  this.history.push(complete)
342
+ this.updatedAt = new Date().toISOString()
255
343
  this.listeners.forEach((listener) => listener(complete))
256
344
  this.persist()
257
345
  }
258
346
 
347
+ /** A user receipt is not externally visible until it is durable. */
348
+ private async recordDurably(event: Omit<SessionEvent, 'sequence' | 'sessionId'>): Promise<void> {
349
+ const complete = { ...event, sequence: this.sequence++, sessionId: this.id }
350
+ this.updatedAt = new Date().toISOString()
351
+ let resolve!: () => void
352
+ let reject!: (error: Error) => void
353
+ const receipt = new Promise<void>((ok, fail) => { resolve = ok; reject = fail })
354
+ void receipt.catch(() => {})
355
+ this.pendingReceipts.set(complete.clientMessageId!, { event: complete, receipt })
356
+ this.persist()
357
+ try {
358
+ await this.flush()
359
+ this.pendingReceipts.delete(complete.clientMessageId!)
360
+ this.history.push(complete)
361
+ this.listeners.forEach((listener) => listener(complete))
362
+ resolve()
363
+ } catch (error) {
364
+ this.pendingReceipts.delete(complete.clientMessageId!)
365
+ reject(error instanceof Error ? error : new Error(String(error)))
366
+ throw error
367
+ }
368
+ }
369
+
259
370
  private persist(): void {
260
371
  if (!this.save || this.persistenceError) return
261
372
  const snapshot = this.snapshot()
@@ -272,12 +383,15 @@ export class Session {
272
383
 
273
384
  export type SessionSnapshot = {
274
385
  id: string
275
- backend: AgentName
386
+ backend: BackendName
276
387
  buildMode: boolean
388
+ owner?: string
277
389
  status: SessionStatus
278
390
  active: boolean
279
391
  history: SessionEvent[]
280
- threadId?: string
392
+ harness?: unknown
393
+ transcript?: unknown
394
+ updatedAt?: string
281
395
  }
282
396
 
283
397
  export class SessionManager {
@@ -288,8 +402,8 @@ export class SessionManager {
288
402
 
289
403
  private persist = async (): Promise<void> => this.save?.([...this.sessions.values()].map((session) => session.snapshot()))
290
404
 
291
- async start(backend: AgentName, worker: SessionBackend, buildMode = false): Promise<Session> {
292
- const session = new Session(backend, worker, randomUUID(), buildMode, this.persist)
405
+ async start(backend: BackendName, worker: SessionBackend, buildMode = false, owner?: string): Promise<Session> {
406
+ const session = new Session(backend, worker, randomUUID(), buildMode, this.persist, owner)
293
407
  this.sessions.set(session.id, session)
294
408
  try {
295
409
  await session.start()
@@ -303,10 +417,33 @@ export class SessionManager {
303
417
 
304
418
  get(id: string): Session | undefined { return this.sessions.get(id) }
305
419
 
420
+ all(): Session[] { return [...this.sessions.values()] }
421
+
422
+ /** The most recently active conversation among those `visible` allows; build conversations by default. */
423
+ latest(visible: (session: Session) => boolean = (session) => session.buildMode): Session | undefined {
424
+ let latest: Session | undefined
425
+ for (const session of this.sessions.values()) {
426
+ if (!visible(session)) continue
427
+ const candidate = session.snapshot().updatedAt
428
+ const current = latest?.snapshot().updatedAt
429
+ if (!latest || (candidate && (!current || candidate >= current)) || (!candidate && !current)) latest = session
430
+ }
431
+ return latest
432
+ }
433
+
306
434
  restore(snapshots: SessionSnapshot[], createWorker: (snapshot: SessionSnapshot) => SessionBackend): void {
307
435
  for (const snapshot of snapshots) this.sessions.set(snapshot.id, Session.restore(snapshot, createWorker(snapshot), this.persist))
308
436
  }
309
437
 
438
+ /**
439
+ * One agent per window: the app's tmux session has a `builder` window and a `chat` window, and a
440
+ * terminal-agent conversation lives in the one its `buildMode` names. Parks every other terminal-agent
441
+ * conversation of that window before another starts or resumes there.
442
+ */
443
+ async parkOthers(buildMode: boolean, id?: string): Promise<void> {
444
+ await Promise.all(this.all().filter((session) => session.id !== id && session.backend !== 'anthropic' && session.buildMode === buildMode).map((session) => session.park()))
445
+ }
446
+
310
447
  async shutdownAll(): Promise<void> {
311
448
  await Promise.all([...this.sessions.values()].map((session) => session.shutdown()))
312
449
  }
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import type { AgentName } from './discovery.ts'
6
+ import type { BackendEvent, PaneAccess, SessionBackend, SlashCommand } from './session.ts'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
10
+
11
+ /** The Bridge Commander harness ref: enough to find, kill or resume the tmux session after a restart. */
12
+ export type HarnessRef = { harness: string; session: string; cwd: string; resumeId?: string; window?: string }
13
+
14
+ /** The seven-verb harness port (src/runtime/harness/README in bridge-commander); `fake.js` for tests. */
15
+ export type Harness = {
16
+ spawn(cwd: string, prompt: string, opts: object): Promise<HarnessRef>
17
+ send(ref: HarnessRef, text: string): Promise<void>
18
+ resume(ref: HarnessRef, opts: object): Promise<HarnessRef>
19
+ kill(ref: HarnessRef): Promise<void> | void
20
+ onTurnEnd(ref: HarnessRef, hook: (event: { session_id?: string | null }) => void, opts: object): () => void
21
+ paneInput?(ref: HarnessRef, input: { key?: string; text?: string }): Promise<void>
22
+ openPane?(ref: HarnessRef, opts: { onFrame: (frame: string) => void }): Promise<PaneHandle> | PaneHandle
23
+ paneSnapshot?(ref: HarnessRef): Promise<string>
24
+ commands?(ref: HarnessRef): SlashCommand[]
25
+ runCommand?(ref: HarnessRef, line: string): Promise<string>
26
+ /** Pins a session-granular ref (pre-window conversations) to a named window without restarting the agent. */
27
+ adoptWindow?(ref: HarnessRef, window: string): Promise<HarnessRef | null>
28
+ }
29
+ export type PaneHandle = { close(): void }
30
+
31
+ export const harnesses: Record<AgentName, Harness> = {
32
+ claude: require('./harness/claude-tmux.js'),
33
+ codex: require('./harness/codex-tmux.js'),
34
+ }
35
+
36
+ export function builderInstructions(cwd: string): string {
37
+ const source = process.env.GOLEM_SOURCE ? resolve(process.env.GOLEM_SOURCE) : frameworkRoot
38
+ const ui = process.env.GOLEM_UI_SOURCE ? resolve(process.env.GOLEM_UI_SOURCE) : undefined
39
+ const guide = resolve(source, 'docs/builder.md')
40
+ return `You are Golem's in-app builder. The user sees Chat beside their application's Canvas. Explain that plainly and ask what they want to create; do not redirect them to generic coding-assistant documentation. Build-mode turns may edit the app and, after success, Golem rebuilds and refreshes the Canvas. Conversation history persists, including resumed threads. Read ${existsSync(guide) ? guide : resolve(frameworkRoot, 'docs/builder.md')} and ${resolve(cwd, 'docs/domain.md')} when present before major work, implementation, or opening/updating a pull request. This app is ${cwd}; active Golem source is ${source}${ui ? `; active golem-ui source is ${ui}` : ''}. Keep app-specific decisions in the app and shared framework/UI knowledge in its owner. The user only sees what you send with \`./golem say <text>\` (or \`./golem say --file <f>\`) from the app root; answer every message that way, nothing printed in this terminal reaches them.${existsSync(resolve(cwd, 'brain/index.md')) ? ` ${brainInstructions}` : ''}`
41
+ }
42
+
43
+ /** The `chat` window's brief: the app's `docs/chat.md` when present, else a plain assistant; never a builder. */
44
+ export function chatInstructions(cwd: string): string {
45
+ const brief = resolve(cwd, 'docs/chat.md')
46
+ const own = existsSync(brief) ? readFileSync(brief, 'utf8').trim() : `You are the assistant of the application in ${cwd}. People chat with you beside the running app; help them use it and answer questions about it.`
47
+ return `${own} You are not this app's builder: do not edit its files, run builds, or change its configuration; if asked to, say the Builder switch is for that. The user only sees what you send with \`./golem say <text>\` (or \`./golem say --file <f>\`) from the app root; answer every message that way, nothing printed in this terminal reaches them.${existsSync(resolve(cwd, 'brain/index.md')) ? ` ${brainInstructions}` : ''}`
48
+ }
49
+
50
+ /** Added when the app has a `brain/` folder: read the root index first, cite what you used. */
51
+ export const brainInstructions = 'This app has a brain: `brain/` is an Open Knowledge Format bundle. Read `brain/index.md` first, then the concepts it points to. When an answer is grounded in the brain, cite each passage you used as `path#Lstart-Lend` (the path relative to `brain/`, e.g. `concepts/opening.md#L4-L9`); Golem turns those citations into source chips under your reply that open the passage in the reader.'
52
+
53
+ /**
54
+ * `window` names this agent's window in the app's session (`builder`, `chat`); `instructions` its launch prompt;
55
+ * `permissions` its launch profile: `bypass` (default) may do anything, `readonly` can read the app and run
56
+ * `./golem say`, nothing else, and refuses rather than prompts.
57
+ */
58
+ export type TmuxOptions = { harness?: Harness; stateDir?: string; api?: string; window?: string; instructions?: string; permissions?: 'bypass' | 'readonly' }
59
+
60
+ /** The one tmux session of an app's build mode: `tmux attach -t golem-<app dir>` is always the place to look. */
61
+ export const tmuxSessionName = (cwd: string): string => `golem-${basename(cwd).replace(/[^A-Za-z0-9_-]/g, '-')}`
62
+
63
+ /**
64
+ * One agent per app, in the fixed tmux session `golem-<app dir>` (attach to watch or take over). A
65
+ * conversation whose agent was killed to make room for another resumes it there on its next message
66
+ * (`SessionManager.parkOthers`). `send` types the message with verified submit and resolves at the agent's turn end
67
+ * (Stop hook / codex notify); the reply itself arrives through `golem say`, never from the pane.
68
+ */
69
+ export class TmuxBackend implements SessionBackend {
70
+ private ref: HarnessRef | undefined
71
+ private emit!: (event: BackendEvent) => void
72
+ private turnEnded: (() => void) | undefined
73
+ private unsubscribe: (() => void) | undefined
74
+ private readonly harness: Harness
75
+ private readonly opts: TmuxOptions
76
+
77
+ private readonly cwd: string
78
+ private readonly agent: AgentName
79
+
80
+ constructor(cwd: string, agent: AgentName, ref?: HarnessRef, opts: TmuxOptions = {}) {
81
+ this.cwd = cwd
82
+ this.agent = agent
83
+ this.ref = ref
84
+ this.opts = opts
85
+ this.harness = opts.harness ?? harnesses[agent]
86
+ }
87
+
88
+ async start(emit: (event: BackendEvent) => void, sessionId: string): Promise<void> {
89
+ this.emit = emit
90
+ const opts = {
91
+ stateDir: this.opts.stateDir ?? resolve(this.cwd, '.golem/harness'),
92
+ session: tmuxSessionName(this.cwd),
93
+ window: this.opts.window,
94
+ permissions: this.opts.permissions,
95
+ env: { GOLEM_SESSION: sessionId, GOLEM_API: this.opts.api ?? 'http://127.0.0.1:3000' },
96
+ // codex 0.155: the update prompt at launch would take the typed brief as its answer, the
97
+ // paste-burst fold swallows the first Enter of a long line, and the rate-limit "keep current
98
+ // model" nudge after the first turn eats the first message. All off; replayed on resume.
99
+ extraArgs: this.agent === 'codex' ? ['-c', 'check_for_update_on_startup=false', '-c', 'disable_paste_burst=true', '-c', 'notice.hide_rate_limit_model_nudge=true'] : [],
100
+ }
101
+ try {
102
+ // A ref saved under an older naming (`golem-<uuid>`) comes back in the app's fixed session: only
103
+ // resumeId carries continuity, and the stray session, if still up, would break one-session-per-app.
104
+ if (this.ref && this.ref.session !== opts.session) {
105
+ await this.harness.kill(this.ref)
106
+ this.ref = { ...this.ref, session: opts.session, window: opts.window }
107
+ }
108
+ // A conversation saved before windows existed owned the whole session: pin it to its window first.
109
+ if (this.ref && !this.ref.window && opts.window) this.ref = (await this.harness.adoptWindow?.(this.ref, opts.window)) ?? this.ref
110
+ // Whoever starts in a window owns it. A previous occupant without a live Session (parked before the
111
+ // last restart, never resumed since) still holds `session:window` in tmux; it resumes through its own
112
+ // ref later, so clearing the window here loses nothing and spares spawn the "already exists" error.
113
+ if (!this.ref && opts.window) await this.harness.kill({ harness: this.agent, session: opts.session, cwd: this.cwd, window: opts.window })
114
+ this.ref = this.ref ? await this.harness.resume(this.ref, opts) : await this.harness.spawn(this.cwd, this.opts.instructions ?? builderInstructions(this.cwd), opts)
115
+ } catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error)
117
+ emit({ type: 'error', message: `${this.agent} session failed to start: ${message}` })
118
+ throw error
119
+ }
120
+ this.unsubscribe = this.harness.onTurnEnd(this.ref, (event) => {
121
+ if (event.session_id) this.ref!.resumeId = event.session_id // codex adopts its thread id from the first turn end
122
+ this.turnEnded?.()
123
+ }, opts)
124
+ }
125
+
126
+ async send(text: string): Promise<void> {
127
+ if (this.turnEnded) throw new Error(`${this.agent} is already handling a request`)
128
+ // ponytail: the next turn end is taken as this message's; a launch-prompt turn still running when
129
+ // the first message lands ends it early. The reply still arrives via `golem say` either way.
130
+ const done = new Promise<void>((resolve) => { this.turnEnded = resolve })
131
+ try { await this.harness.send(this.ref!, text) } catch (error) { this.turnEnded = undefined; throw error }
132
+ await done
133
+ this.turnEnded = undefined
134
+ }
135
+
136
+ async interrupt(): Promise<void> {
137
+ if (!this.ref) return
138
+ await this.harness.paneInput?.(this.ref, { key: 'C-c' })
139
+ this.turnEnded?.()
140
+ this.turnEnded = undefined
141
+ }
142
+
143
+ /** The server is stopping, the agent is not: the tmux session outlives it and is resumed on restart. */
144
+ async detach(): Promise<void> { this.unsubscribe?.() }
145
+
146
+ async shutdown(): Promise<void> {
147
+ this.unsubscribe?.()
148
+ this.turnEnded?.() // a send cut short by the kill still resolves; its reply will never come
149
+ this.turnEnded = undefined
150
+ if (this.ref) await this.harness.kill(this.ref)
151
+ }
152
+
153
+ harnessRef(): HarnessRef | undefined { return this.ref }
154
+
155
+ /** The harness's own slash commands (`/status`, `/compact`, …); `/reset` is the server's, not listed here. */
156
+ commands(): SlashCommand[] { return this.ref && this.harness.commands ? this.harness.commands(this.ref) : [] }
157
+
158
+ async runCommand(line: string): Promise<string> {
159
+ if (!this.ref || !this.harness.runCommand) throw new Error(`unknown command ${line.split(/\s+/)[0]}`)
160
+ return this.harness.runCommand(this.ref, line)
161
+ }
162
+
163
+ /** The live tmux screen, for the Terminal popup: undefined until the agent has been spawned or resumed. */
164
+ pane(): PaneAccess | undefined {
165
+ const { harness, ref } = this
166
+ if (!ref || !harness.openPane) return undefined
167
+ return {
168
+ open: (onFrame) => harness.openPane!(ref, { onFrame }),
169
+ snapshot: () => harness.paneSnapshot?.(ref) ?? Promise.resolve(''),
170
+ input: (input) => harness.paneInput?.(ref, input) ?? Promise.reject(new Error('harness cannot take pane input')),
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Operation names as Anthropic tool names, which must match ^[a-zA-Z0-9_-]{1,128}$. A dot becomes
3
+ * `__`, which is only safe while no two listed operations encode alike (`a.b` and `a__b`, or `a._b`
4
+ * and `a_.b`), so a list that would is refused rather than sent.
5
+ */
6
+ export function toolName(operation: string): string { return operation.replaceAll('.', '__') }
7
+
8
+ /** The reason these operations cannot all be tools, or undefined when each maps to its own valid name. */
9
+ export function toolNameProblem(operations: string[]): string | undefined {
10
+ const seen = new Map<string, string>()
11
+ for (const operation of operations) {
12
+ const name = toolName(operation)
13
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(name)) return `'${operation}' is not a valid tool name: use letters, digits, '_', '-' and '.', at most 128 characters once each '.' becomes '__'`
14
+ const other = seen.get(name)
15
+ if (other !== undefined && other !== operation) return `'${other}' and '${operation}' both become the tool name '${name}'`
16
+ seen.set(name, operation)
17
+ }
18
+ return undefined
19
+ }
@@ -0,0 +1,56 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ /**
4
+ * Source mode has to cover the app's own imports, not just the shell's: the app's node_modules
5
+ * still holds the published golem-kit and golem-ui. One table of module specifier to file,
6
+ * shared by the browser bundle, the app server bundle and the app typecheck. A trailing `/*`
7
+ * is a prefix mapping, spelled the way tsconfig `paths` spells it.
8
+ */
9
+ export function sourceModules(): Record<string, string> {
10
+ const modules: Record<string, string> = {}
11
+ const kit = process.env.GOLEM_SOURCE && resolve(process.env.GOLEM_SOURCE)
12
+ // golem-kit's own exports map, answered from the checkout instead of node_modules.
13
+ if (kit) Object.assign(modules, {
14
+ 'golem-kit/client': resolve(kit, 'src/client.ts'),
15
+ 'golem-kit/server': resolve(kit, 'src/backend/index.ts'),
16
+ 'golem-kit/operations': resolve(kit, 'src/operations.ts'),
17
+ 'golem-kit/*': resolve(kit, '*'),
18
+ })
19
+ // golem-ui publishes only dist/, so source mode answers from src/ and no `pnpm build` is needed.
20
+ const ui = process.env.GOLEM_UI_SOURCE && resolve(process.env.GOLEM_UI_SOURCE)
21
+ if (ui) Object.assign(modules, {
22
+ 'golem-ui': resolve(ui, 'src/index.ts'),
23
+ 'golem-ui/styles.css': resolve(ui, 'src/styles.css'),
24
+ })
25
+ return modules
26
+ }
27
+
28
+ /** The same table as tsconfig `paths`, for a `tsc` run over the app's own files. */
29
+ export function sourcePaths(): Record<string, string[]> {
30
+ return Object.fromEntries(Object.entries(sourceModules()).map(([specifier, file]) => [specifier, [file]]))
31
+ }
32
+
33
+ /** The same table as Vite `resolve.alias` entries; exact specifiers sort before `/*` prefixes. */
34
+ export function sourceAliases(): { find: RegExp; replacement: string }[] {
35
+ return Object.entries(sourceModules())
36
+ .sort(([a], [b]) => Number(a.endsWith('/*')) - Number(b.endsWith('/*')))
37
+ .map(([specifier, file]) => specifier.endsWith('/*')
38
+ ? { find: new RegExp(`^${escape(specifier.slice(0, -1))}(.+)$`), replacement: file.replace(/\*$/, '$1') }
39
+ : { find: new RegExp(`^${escape(specifier)}$`), replacement: file })
40
+ }
41
+
42
+ /** The file a bare specifier resolves to in source mode, or undefined when it is not ours to answer. */
43
+ export function resolveSourceModule(specifier: string): string | undefined {
44
+ const modules = sourceModules()
45
+ if (modules[specifier]) return modules[specifier]
46
+ for (const [pattern, file] of Object.entries(modules)) {
47
+ if (pattern.endsWith('/*') && specifier.startsWith(pattern.slice(0, -1))) {
48
+ return file.replace(/\*$/, specifier.slice(pattern.length - 1))
49
+ }
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ function escape(text: string): string {
55
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
+ }
package/vite.config.ts CHANGED
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { defineConfig, type UserConfig } from 'vite'
6
6
  import react from '@vitejs/plugin-react'
7
+ import { sourceAliases } from './src/source-mode.ts'
7
8
 
8
9
  const frameworkRoot = import.meta.dirname
9
10
 
@@ -61,10 +62,7 @@ export default defineConfig(async (): Promise<UserConfig> => {
61
62
  plugins,
62
63
  resolve: {
63
64
  alias: [
64
- ...(ui ? [
65
- { find: /^golem-ui$/, replacement: resolve(ui.root, 'src/index.ts') },
66
- { find: /^golem-ui\/styles\.css$/, replacement: resolve(ui.root, 'src/styles.css') },
67
- ] : []),
65
+ ...sourceAliases(),
68
66
  { find: /^@golem\/app$/, replacement: resolve(appRoot, 'src/app.tsx') },
69
67
  { find: /^@golem\/config$/, replacement: resolve(appRoot, 'golem.config.ts') },
70
68
  ],