golem-kit 0.1.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/README.md +41 -0
- package/assets/golem-mascot.png +0 -0
- package/docs/local-cli.md +64 -0
- package/docs/source-development.md +65 -0
- package/golem.config.ts +1 -0
- package/index.html +12 -0
- package/package.json +48 -0
- package/src/app.tsx +10 -0
- package/src/browser/adapters.ts +164 -0
- package/src/browser/app.d.ts +9 -0
- package/src/browser/app.tsx +63 -0
- package/src/browser/main.tsx +9 -0
- package/src/browser/vite-env.d.ts +1 -0
- package/src/browser-build.ts +70 -0
- package/src/cli.ts +127 -0
- package/src/dev-server.ts +190 -0
- package/src/runtime/codex.ts +119 -0
- package/src/runtime/discovery.ts +92 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/session.ts +326 -0
- package/src/runtime/state.ts +42 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Golem
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
## What is Golem?
|
|
6
|
+
|
|
7
|
+
Golem is an early local application shell for building applications with AI. **Your app is its own IDE:** enter build mode, ask a locally authenticated Codex to edit the app, and keep using it in the same browser shell.
|
|
8
|
+
|
|
9
|
+
## Getting Started
|
|
10
|
+
|
|
11
|
+
Install Node.js >=22.18.0 and pnpm 10.28.2. Then run:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
mkdir my-app
|
|
15
|
+
cd my-app
|
|
16
|
+
npx golem-kit init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Start the application:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
./golem dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Open the browser address printed in the terminal, enter build mode, and start a conversation. Build chat requires the Codex CLI installed and authenticated on the same machine. After a successful build-mode change, the browser shell rebuilds and refreshes; conversations are saved in `.golem/` and restored when the server restarts.
|
|
26
|
+
|
|
27
|
+
`./golem build` writes the browser shell to `dist/`. `./golem dev` binds only to `127.0.0.1:3000`.
|
|
28
|
+
|
|
29
|
+
## Local source development
|
|
30
|
+
|
|
31
|
+
Use an installed package by default, or opt into a durable checkout while developing Golem:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
GOLEM_SOURCE=/path/to/golem ./golem dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See [the local CLI guide](docs/local-cli.md) for the complete command contract.
|
|
38
|
+
|
|
39
|
+
## Current scope
|
|
40
|
+
|
|
41
|
+
Claude integration, domain storage, accounts, and permissions are planned, not part of this release.
|
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Local command contract
|
|
2
|
+
|
|
3
|
+
Use Node.js >=22.18.0 (native TypeScript execution) and pnpm 10.28.2.
|
|
4
|
+
Run `pnpm install`, then `./golem help`. The executable wrapper resolves the
|
|
5
|
+
project-local CLI relative to itself, including when invoked from another directory.
|
|
6
|
+
The browser shell uses the local Vite build and the published `golem-ui` package.
|
|
7
|
+
|
|
8
|
+
## Source resolution seam
|
|
9
|
+
|
|
10
|
+
The root wrapper explicitly selects `src/cli.ts` relative to the wrapper's own
|
|
11
|
+
directory. This is the sole CLI entrypoint resolution point today; there is no
|
|
12
|
+
package-name lookup or resolution configuration. Generated app wrappers add an
|
|
13
|
+
explicit `GOLEM_SOURCE=/path/to/golem` opt-in for running a framework checkout
|
|
14
|
+
while keeping the app cwd. Unset it to use the installed pinned package.
|
|
15
|
+
|
|
16
|
+
The CLI imports `./dev-server.ts` relative to the wrapper. The server serves built `dist/`
|
|
17
|
+
files and does not resolve packages or depend on
|
|
18
|
+
the caller's working directory. Keep source/package selection outside this server
|
|
19
|
+
boundary so a hot-source workflow can select the CLI without changing HTTP startup.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
| Command | Behavior | Exit status |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `./golem help` (or `./golem`) | List all commands | 0 |
|
|
26
|
+
| `./golem dev` | Refresh the browser build, then serve it at `http://127.0.0.1:3000/` until SIGINT/SIGTERM | 0 on clean shutdown; 1 on startup failure |
|
|
27
|
+
| `./golem build` | Build the browser shell into `dist/` | 0 |
|
|
28
|
+
| `./golem doctor` | Report local shell and backend readiness | 0 |
|
|
29
|
+
|
|
30
|
+
Unknown commands and extra arguments exit 2. Built assets are served directly; extensionless browser
|
|
31
|
+
routes fall back to `index.html`, while missing assets return 404. Malformed URLs return 400. The
|
|
32
|
+
server binds to loopback only. Port 3000 must be free.
|
|
33
|
+
`doctor` succeeding means its report ran, not that the full product is ready.
|
|
34
|
+
|
|
35
|
+
`src/dev-server.ts` exports `startDevServer(port = 3000)`, resolving to a listening
|
|
36
|
+
Node HTTP server. It refreshes the browser build before listening; the CLI owns signal handling and output. `src/browser/app.tsx` is the
|
|
37
|
+
composition boundary: anonymous identity and browser navigation are explicit host adapters, while
|
|
38
|
+
the chat adapter connects explicit browser build-mode sessions to the local Codex runtime.
|
|
39
|
+
|
|
40
|
+
Check types with `pnpm exec tsc --noEmit`; run the CLI/HTTP smoke check with
|
|
41
|
+
`node --test --test-concurrency=1 test/*.mjs` (serial because the CLI test intentionally removes
|
|
42
|
+
and rebuilds the shared `dist/` directory; requires port 3000).
|
|
43
|
+
|
|
44
|
+
## Generated projects
|
|
45
|
+
|
|
46
|
+
`golem-kit init` creates `package.json`, `golem.config.ts`, `src/app.tsx`,
|
|
47
|
+
`docs/domain.md`, and an executable `./golem`. Normal initialization writes the
|
|
48
|
+
pinned npm dependency `golem-kit@<framework version>` and installs it with pnpm.
|
|
49
|
+
For local packed-tarball acceptance only, set `GOLEM_KIT_TARBALL=/path/to/golem-kit.tgz`.
|
|
50
|
+
An existing package is supported only when it already declares `golem-kit`; its
|
|
51
|
+
metadata is preserved. Other nonempty destinations are refused.
|
|
52
|
+
|
|
53
|
+
Two-checkout development:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
GOLEM_SOURCE=/path/to/golem /path/to/app/golem build
|
|
57
|
+
GOLEM_SOURCE=/path/to/golem /path/to/app/golem dev
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The wrapper changes into the app first, so `src/` and `dist/` remain app-owned.
|
|
61
|
+
The framework checkout uses its own installed dependencies. Source-mode builds
|
|
62
|
+
print framework path and short git revision, plus the same fields for
|
|
63
|
+
`GOLEM_UI_SOURCE` when that override is set. Omit both overrides to return to the
|
|
64
|
+
installed pinned package.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Local UI source development
|
|
2
|
+
|
|
3
|
+
The normal Golem checkout uses the pinned published `golem-ui@0.1.1` dependency. For a local
|
|
4
|
+
change to `golem-ui`, set `GOLEM_UI_SOURCE` to an isolated UI checkout for that command. Golem
|
|
5
|
+
aliases the package entry point and stylesheet to `src/index.ts` and `src/styles.css`, while
|
|
6
|
+
deduplicating React with the Golem checkout. It also loads the UI checkout's installed
|
|
7
|
+
`@tailwindcss/vite` plugin and adds the checkout as a Tailwind `@source`, so utility classes in
|
|
8
|
+
edited UI components are compiled and scanned.
|
|
9
|
+
|
|
10
|
+
## Clean checkout
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
git clone https://github.com/tonylampada/golem.git
|
|
14
|
+
git clone https://github.com/tonylampada/golem-ui.git
|
|
15
|
+
cd golem
|
|
16
|
+
pnpm install
|
|
17
|
+
cd ../golem-ui
|
|
18
|
+
pnpm install
|
|
19
|
+
cd ../golem
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run the Golem checkout against the checked-out UI source:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
GOLEM_UI_SOURCE=../golem-ui ./golem dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The build prints both checkout paths and their short git revisions in source mode. `./golem
|
|
29
|
+
build` accepts the same override. The path must contain the golem-ui `package.json`, `src/index.ts`,
|
|
30
|
+
and `src/styles.css`; invalid paths fail with an explanatory error.
|
|
31
|
+
|
|
32
|
+
To return to the published dependency, omit the variable:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
./golem dev
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With the server running, these direct browser assertions check the expected placeholder, computed
|
|
39
|
+
font/display, and desktop/mobile chat/canvas geometry. Install Playwright and its managed Chromium
|
|
40
|
+
in a temporary directory. Set GOLEM_BROWSER_EXECUTABLE only when using another browser binary:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
browser_tools=$(mktemp -d)
|
|
44
|
+
npm --prefix "$browser_tools" install --no-save playwright
|
|
45
|
+
"$browser_tools/node_modules/.bin/playwright" install chromium
|
|
46
|
+
export PLAYWRIGHT_MODULE="$browser_tools/node_modules/playwright/index.mjs"
|
|
47
|
+
GOLEM_UI_SOURCE=../golem-ui GOLEM_EXPECTED_PLACEHOLDER='Message the agent…' GOLEM_BROWSER_SCREENSHOT=.artifacts/source \
|
|
48
|
+
node scripts/browser-assertions.mjs
|
|
49
|
+
unset GOLEM_UI_SOURCE GOLEM_EXPECTED_PLACEHOLDER
|
|
50
|
+
GOLEM_BROWSER_SCREENSHOT=.artifacts/published node scripts/browser-assertions.mjs
|
|
51
|
+
rm -rf "$browser_tools"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For example, an explicit browser binary can be selected with `GOLEM_BROWSER_EXECUTABLE=/path/to/chrome`.
|
|
55
|
+
|
|
56
|
+
For a temporary source marker experiment, set GOLEM_EXPECTED_PLACEHOLDER to the marker after
|
|
57
|
+
editing the isolated UI checkout. Ordinary source mode expects the normal Message the agent… placeholder.
|
|
58
|
+
No package scripts or lockfiles need to change when switching modes. `golem-ui` itself uses Vite
|
|
59
|
+
and its package build is `pnpm build`; source mode consumes its TypeScript entry point directly.
|
|
60
|
+
|
|
61
|
+
For generated apps, `GOLEM_SOURCE=/path/to/golem /path/to/app/golem dev` opts into
|
|
62
|
+
the framework checkout while preserving the app cwd and lockfile. Omit
|
|
63
|
+
`GOLEM_SOURCE` to use the installed pinned `golem-kit`; combine it with
|
|
64
|
+
`GOLEM_UI_SOURCE=/path/to/golem-ui` when developing both checkouts. Source-mode
|
|
65
|
+
builds print each source path and short git revision.
|
package/golem.config.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Golem' }
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Golem</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/browser/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "golem-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Starter kit and local CLI for Golem applications.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"README.md",
|
|
8
|
+
"assets/golem-mascot.png",
|
|
9
|
+
"docs/local-cli.md",
|
|
10
|
+
"docs/source-development.md",
|
|
11
|
+
"golem.config.ts",
|
|
12
|
+
"index.html",
|
|
13
|
+
"src",
|
|
14
|
+
"tsconfig.json",
|
|
15
|
+
"vite.config.ts"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/tonylampada/golem.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/tonylampada/golem/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/tonylampada/golem#readme",
|
|
25
|
+
"packageManager": "pnpm@10.28.2",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.18.0",
|
|
28
|
+
"pnpm": "10.28.2"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"golem-kit": "src/cli.ts"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@types/node": "22.20.3",
|
|
38
|
+
"@types/react": "19.2.2",
|
|
39
|
+
"@types/react-dom": "19.2.1",
|
|
40
|
+
"@vitejs/plugin-react": "5.0.4",
|
|
41
|
+
"typescript": "7.0.2",
|
|
42
|
+
"vite": "7.1.9",
|
|
43
|
+
"tsx": "4.23.13",
|
|
44
|
+
"golem-ui": "0.1.1",
|
|
45
|
+
"react": "19.2.0",
|
|
46
|
+
"react-dom": "19.2.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export default function App() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="flex h-full min-h-64 items-center justify-center bg-neutral-50 p-6 text-center">
|
|
4
|
+
<div>
|
|
5
|
+
<h1 className="text-lg font-semibold">Welcome to Golem</h1>
|
|
6
|
+
<p className="mt-2 text-sm text-neutral-500">Edit src/app.tsx to build your app.</p>
|
|
7
|
+
</div>
|
|
8
|
+
</section>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ChatAdapter, ChatAttachment, ChatMessage, IdentityAdapter, NavigationAdapter, Route, User } from 'golem-ui'
|
|
2
|
+
|
|
3
|
+
const unavailable = () => Promise.reject(new Error('No agent or identity service is connected.'))
|
|
4
|
+
|
|
5
|
+
/** Anonymous means only that this host has no sign-in service; it grants no authorization. */
|
|
6
|
+
export const anonymousIdentity: IdentityAdapter = {
|
|
7
|
+
currentUser: async () => null,
|
|
8
|
+
subscribe: () => () => {},
|
|
9
|
+
signIn: unavailable,
|
|
10
|
+
signUp: unavailable,
|
|
11
|
+
requestCode: unavailable,
|
|
12
|
+
verifyCode: unavailable,
|
|
13
|
+
signOut: async () => {},
|
|
14
|
+
listMembers: async () => [],
|
|
15
|
+
invite: unavailable,
|
|
16
|
+
removeMember: unavailable,
|
|
17
|
+
setRole: unavailable,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function route(): Route {
|
|
21
|
+
return { path: window.location.pathname, params: Object.fromEntries(new URLSearchParams(window.location.search)) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const navigation: NavigationAdapter = {
|
|
25
|
+
current: route,
|
|
26
|
+
go(path) { window.history.pushState({}, '', path); window.dispatchEvent(new PopStateEvent('popstate')) },
|
|
27
|
+
subscribe(listener) {
|
|
28
|
+
const update = () => listener(route())
|
|
29
|
+
window.addEventListener('popstate', update)
|
|
30
|
+
return () => window.removeEventListener('popstate', update)
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const storageKey = 'golem.browser.session'
|
|
35
|
+
let sessionId: string | undefined = (() => {
|
|
36
|
+
try { return window.sessionStorage.getItem(storageKey) ?? undefined } catch { return undefined }
|
|
37
|
+
})()
|
|
38
|
+
let messages: ChatMessage[] = []
|
|
39
|
+
let cursor = -1
|
|
40
|
+
let eventLog = new Map<number, { sequence: number; type: string; text?: string; status?: string; reason?: string }>()
|
|
41
|
+
let status = 'starting'
|
|
42
|
+
let source: EventSource | undefined
|
|
43
|
+
const listeners = new Set<(messages: ChatMessage[]) => void>()
|
|
44
|
+
const statusListeners = new Set<(status: string) => void>()
|
|
45
|
+
const emit = () => listeners.forEach((listener) => listener([...messages]))
|
|
46
|
+
const setStatus = (next: string) => { status = next; statusListeners.forEach((listener) => listener(status)) }
|
|
47
|
+
|
|
48
|
+
function mergeEvents(events: Array<{ sequence: number; type: string; text?: string; status?: string; reason?: string }>): string | undefined {
|
|
49
|
+
for (const event of events) if (!eventLog.has(event.sequence)) eventLog.set(event.sequence, event)
|
|
50
|
+
const ordered = [...eventLog.values()].sort((left, right) => left.sequence - right.sequence)
|
|
51
|
+
cursor = Math.max(cursor, ...ordered.map((event) => event.sequence))
|
|
52
|
+
messages = fromEvents(ordered)
|
|
53
|
+
return ordered.findLast((event) => event.type === 'status')?.status
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fromEvents(events: Array<{ type: string; sequence: number; text?: string; status?: string; reason?: string }>): ChatMessage[] {
|
|
57
|
+
return events.flatMap((event) => {
|
|
58
|
+
if ((event.type === 'user' || event.type === 'message') && event.text) {
|
|
59
|
+
return [{ id: `${event.sequence}`, role: event.type === 'user' ? 'user' : 'agent', text: event.text, at: new Date().toISOString() }]
|
|
60
|
+
}
|
|
61
|
+
if (event.type === 'error') return [{ id: `${event.sequence}`, role: 'agent', text: `Error: ${event.text ?? 'Agent failed.'}`, at: new Date().toISOString() }]
|
|
62
|
+
if (event.type === 'interrupted') return [{ id: `${event.sequence}`, role: 'agent', text: `Interrupted${event.reason ? `: ${event.reason}` : '.'}`, at: new Date().toISOString() }]
|
|
63
|
+
return []
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The only session-starting call in the UI — declares build intent explicitly; the server decides permission from it. */
|
|
68
|
+
export async function startBrowserSession(): Promise<{ id: string; backend: string }> {
|
|
69
|
+
const response = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backend: 'codex', intent: 'build' }) })
|
|
70
|
+
const result = await response.json() as { id?: string; backend?: string; error?: string }
|
|
71
|
+
if (!response.ok || !result.id) throw new Error(result.error ?? 'Unable to start session')
|
|
72
|
+
source?.close()
|
|
73
|
+
source = undefined
|
|
74
|
+
sessionId = result.id
|
|
75
|
+
window.sessionStorage.setItem(storageKey, sessionId)
|
|
76
|
+
cursor = -1
|
|
77
|
+
eventLog = new Map()
|
|
78
|
+
setStatus('ready')
|
|
79
|
+
messages = []
|
|
80
|
+
emit()
|
|
81
|
+
return { id: result.id, backend: result.backend ?? 'codex' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function currentBrowserSession(): string | undefined { return sessionId }
|
|
85
|
+
|
|
86
|
+
export async function restoreBrowserSession(): Promise<boolean> {
|
|
87
|
+
if (!sessionId) return false
|
|
88
|
+
const response = await fetch(`/api/sessions/${sessionId}/history`)
|
|
89
|
+
if (response.status === 404) {
|
|
90
|
+
sessionId = undefined
|
|
91
|
+
window.sessionStorage.removeItem(storageKey)
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to restore session')
|
|
95
|
+
const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string }>; status: string }
|
|
96
|
+
eventLog = new Map()
|
|
97
|
+
setStatus(mergeEvents(result.events) ?? result.status)
|
|
98
|
+
emit()
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function subscribeBrowserStatus(listener: (status: string) => void): () => void {
|
|
103
|
+
statusListeners.add(listener)
|
|
104
|
+
listener(status)
|
|
105
|
+
return () => statusListeners.delete(listener)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function interruptBrowserSession(): Promise<void> {
|
|
109
|
+
if (!sessionId) return
|
|
110
|
+
const response = await fetch(`/api/sessions/${sessionId}/interrupt`, { method: 'POST', headers: { 'Content-Type': 'application/json' } })
|
|
111
|
+
if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to interrupt session')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applyEvent(event: { sessionId?: string; sequence: number; type: string; text?: string; status?: string; reason?: string }): void {
|
|
115
|
+
if (event.sessionId && event.sessionId !== sessionId) return
|
|
116
|
+
if (event.sequence <= cursor) return
|
|
117
|
+
cursor = event.sequence
|
|
118
|
+
if (event.status) setStatus(event.status)
|
|
119
|
+
// Live-only: a replayed 'rebuilt' from history/reload restoration must never re-trigger this.
|
|
120
|
+
if (event.type === 'rebuilt') { window.location.reload(); return }
|
|
121
|
+
mergeEvents([event])
|
|
122
|
+
emit()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const chat: ChatAdapter = {
|
|
126
|
+
history: async () => {
|
|
127
|
+
if (!sessionId) return []
|
|
128
|
+
const response = await fetch(`/api/sessions/${sessionId}/history`)
|
|
129
|
+
if (!response.ok) throw new Error((await response.json()).error ?? 'Unable to load session history')
|
|
130
|
+
const result = await response.json() as { events: Array<{ sequence: number; type: string; text?: string; reason?: string }>; status: string }
|
|
131
|
+
setStatus(mergeEvents(result.events) ?? result.status)
|
|
132
|
+
emit()
|
|
133
|
+
return [...messages]
|
|
134
|
+
},
|
|
135
|
+
subscribe(listener) {
|
|
136
|
+
listeners.add(listener)
|
|
137
|
+
if (sessionId) {
|
|
138
|
+
source?.close()
|
|
139
|
+
const subscribedSession = sessionId
|
|
140
|
+
let subscribedSource: EventSource
|
|
141
|
+
const connect = () => {
|
|
142
|
+
const nextSource = new EventSource(`/api/sessions/${subscribedSession}/events?after=${cursor}`)
|
|
143
|
+
subscribedSource = nextSource
|
|
144
|
+
source = nextSource
|
|
145
|
+
nextSource.onmessage = (event) => applyEvent(JSON.parse(event.data))
|
|
146
|
+
nextSource.onerror = () => {
|
|
147
|
+
if (source === nextSource && nextSource.readyState === EventSource.CLOSED && sessionId === subscribedSession) {
|
|
148
|
+
connect()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
connect()
|
|
153
|
+
return () => { subscribedSource.close(); if (source === subscribedSource) source = undefined; listeners.delete(listener) }
|
|
154
|
+
}
|
|
155
|
+
return () => listeners.delete(listener)
|
|
156
|
+
},
|
|
157
|
+
async send(text, attachments) {
|
|
158
|
+
if (!sessionId) throw new Error('Enter build mode before sending a message')
|
|
159
|
+
const response = await fetch(`/api/sessions/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachments }) })
|
|
160
|
+
if (!response.ok) throw new Error((await response.json()).error ?? 'Agent request failed')
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type { ChatMessage, User }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Chat, Shell } from 'golem-ui'
|
|
3
|
+
import UserApp from '@golem/app'
|
|
4
|
+
import projectConfig from '@golem/config'
|
|
5
|
+
import { anonymousIdentity, chat, interruptBrowserSession, navigation, restoreBrowserSession, startBrowserSession, subscribeBrowserStatus } from './adapters'
|
|
6
|
+
|
|
7
|
+
const shellAdapters = { identity: anonymousIdentity, navigation }
|
|
8
|
+
const chatAdapters = { chat }
|
|
9
|
+
|
|
10
|
+
export function App() {
|
|
11
|
+
const [mode, setMode] = useState(false)
|
|
12
|
+
const [session, setSession] = useState<string>()
|
|
13
|
+
const [sessionStatus, setSessionStatus] = useState('starting')
|
|
14
|
+
const [enteringBuildMode, setEnteringBuildMode] = useState(false)
|
|
15
|
+
const enteringBuildModeRef = useRef(false)
|
|
16
|
+
const [runtime, setRuntime] = useState<{ codex: boolean; claude: boolean }>({ codex: false, claude: false })
|
|
17
|
+
const [error, setError] = useState<string>()
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
fetch('/api/runtime').then((response) => response.json()).then((result) => {
|
|
20
|
+
const discoveries = result.discoveries as Array<{ agent: string; status: string; runnable: boolean }>
|
|
21
|
+
setRuntime({ codex: discoveries.some((item) => item.agent === 'codex' && item.status === 'available' && item.runnable), claude: false })
|
|
22
|
+
}).catch(() => setError('Runtime discovery unavailable.'))
|
|
23
|
+
}, [])
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const unsubscribe = subscribeBrowserStatus(setSessionStatus)
|
|
26
|
+
restoreBrowserSession().then((restored) => {
|
|
27
|
+
if (restored) { setSession(window.sessionStorage.getItem('golem.browser.session') ?? undefined); setMode(true) }
|
|
28
|
+
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
|
|
29
|
+
return unsubscribe
|
|
30
|
+
}, [])
|
|
31
|
+
const enterBuildMode = async () => {
|
|
32
|
+
if (enteringBuildModeRef.current) return
|
|
33
|
+
enteringBuildModeRef.current = true
|
|
34
|
+
setEnteringBuildMode(true)
|
|
35
|
+
setError(undefined)
|
|
36
|
+
try { const started = await startBrowserSession(); setSession(started.id); setMode(true) }
|
|
37
|
+
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
|
38
|
+
finally { enteringBuildModeRef.current = false; setEnteringBuildMode(false) }
|
|
39
|
+
}
|
|
40
|
+
const interrupt = async () => {
|
|
41
|
+
try { await interruptBrowserSession() } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<Shell
|
|
45
|
+
config={{ title: projectConfig.title, chatSide: 'left', breakpoint: 768 }}
|
|
46
|
+
adapters={shellAdapters}
|
|
47
|
+
chat={
|
|
48
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
49
|
+
<div className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-3 text-sm">
|
|
50
|
+
<span className="font-medium">{mode ? 'Build mode' : 'Conversation mode'}</span>
|
|
51
|
+
<span className={mode ? 'text-green-700' : 'text-neutral-500'}>{mode ? `${sessionStatus} · ${session}` : 'Not connected'}</span>
|
|
52
|
+
{mode && (sessionStatus === 'ready' || sessionStatus === 'starting') && <button className="rounded border border-red-300 px-2 py-1 text-red-700" onClick={interrupt}>Interrupt</button>}
|
|
53
|
+
</div>
|
|
54
|
+
{!mode ? <div className="p-4 text-sm"><p className="text-neutral-600">Codex: {runtime.codex ? 'available' : 'unavailable'} · Claude: not yet connected</p><button className="mt-4 rounded bg-neutral-900 px-3 py-2 text-white disabled:opacity-40" disabled={!runtime.codex || enteringBuildMode} onClick={enterBuildMode}>Enter build mode</button>{error && <p className="mt-3 text-red-700">{error}</p>}</div> : <Chat key={session} config={{ agentName: 'Golem Codex', emptyState: 'Ask Codex to inspect or explain this workspace.' }} adapters={chatAdapters} />}
|
|
55
|
+
</div>
|
|
56
|
+
}
|
|
57
|
+
canvas={
|
|
58
|
+
<UserApp />
|
|
59
|
+
}
|
|
60
|
+
account={<span className="shrink-0 text-sm text-neutral-500">Guest</span>}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import 'golem-ui/styles.css'
|
|
3
|
+
import { App } from './app'
|
|
4
|
+
|
|
5
|
+
document.documentElement.style.height = '100%'
|
|
6
|
+
document.body.style.height = '100%'
|
|
7
|
+
document.body.style.margin = '0'
|
|
8
|
+
document.getElementById('root')!.style.height = '100%'
|
|
9
|
+
createRoot(document.getElementById('root')!).render(<App />)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { build } from 'vite';
|
|
6
|
+
import { resolveUiSource } from '../vite.config.ts';
|
|
7
|
+
|
|
8
|
+
const frameworkRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one build boundary used by both `./golem build` and `./golem dev`.
|
|
12
|
+
* Typecheck runs before the Vite build on purpose: Vite's own `dist/` write only happens once
|
|
13
|
+
* its build succeeds, so checking types first means a type-only failure never touches `dist/`
|
|
14
|
+
* either, matching the same previous-build-preserved-on-failure guarantee a parse failure gets.
|
|
15
|
+
*/
|
|
16
|
+
export async function buildBrowser(): Promise<void> {
|
|
17
|
+
const ui = resolveUiSource();
|
|
18
|
+
if (process.env.GOLEM_SOURCE) console.log(`Golem source: ${frameworkRoot} (${gitRevision(frameworkRoot)})`);
|
|
19
|
+
if (ui) {
|
|
20
|
+
console.log(`Golem source: ${frameworkRoot} (${gitRevision(frameworkRoot)})`);
|
|
21
|
+
console.log(`golem-ui source: ${ui.root} (${ui.revision})`);
|
|
22
|
+
}
|
|
23
|
+
const tsc = resolve(frameworkRoot, 'node_modules/.bin/tsc');
|
|
24
|
+
const typeRoots = existsSync(resolve(frameworkRoot, 'node_modules/@types'))
|
|
25
|
+
? resolve(frameworkRoot, 'node_modules/@types')
|
|
26
|
+
: resolve(frameworkRoot, '../@types');
|
|
27
|
+
execFileSync(tsc, ['--noEmit', '-p', resolve(frameworkRoot, 'tsconfig.json')], {
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
});
|
|
31
|
+
execFileSync(tsc, [
|
|
32
|
+
'--ignoreConfig', '--noEmit', '--jsx', 'react-jsx', '--module', 'ESNext',
|
|
33
|
+
'--moduleResolution', 'Bundler', '--skipLibCheck', '--types', 'node,react,react-dom',
|
|
34
|
+
'--typeRoots', typeRoots,
|
|
35
|
+
resolve(process.cwd(), 'src/app.tsx'), resolve(process.cwd(), 'golem.config.ts'),
|
|
36
|
+
], { cwd: process.cwd(), stdio: 'inherit' });
|
|
37
|
+
await build({ configFile: resolve(frameworkRoot, 'vite.config.ts') });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let buildInFlight: Promise<void> | undefined
|
|
41
|
+
let queued = false
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Server-owned rebuild trigger for after a build-mode turn. Coalesces overlapping calls into
|
|
45
|
+
* one rerun instead of racing concurrent `buildBrowser()` invocations.
|
|
46
|
+
*/
|
|
47
|
+
export function rebuild(): Promise<void> {
|
|
48
|
+
if (buildInFlight) { queued = true; return buildInFlight }
|
|
49
|
+
buildInFlight = runQueuedBuilds()
|
|
50
|
+
return buildInFlight
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runQueuedBuilds(): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
do {
|
|
56
|
+
queued = false
|
|
57
|
+
await buildBrowser()
|
|
58
|
+
} while (queued)
|
|
59
|
+
} finally {
|
|
60
|
+
buildInFlight = undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitRevision(root: string): string {
|
|
65
|
+
try {
|
|
66
|
+
return execFileSync('git', ['-C', root, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
67
|
+
} catch {
|
|
68
|
+
return 'unavailable';
|
|
69
|
+
}
|
|
70
|
+
}
|