golem-kit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +19 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
package/src/runtime/session.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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,
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (this.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
195
|
-
|
|
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
|
-
|
|
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:
|
|
386
|
+
backend: BackendName
|
|
276
387
|
buildMode: boolean
|
|
388
|
+
owner?: string
|
|
277
389
|
status: SessionStatus
|
|
278
390
|
active: boolean
|
|
279
391
|
history: SessionEvent[]
|
|
280
|
-
|
|
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:
|
|
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
|
-
...(
|
|
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
|
],
|