golem-kit 0.2.1 → 0.2.3
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 +9 -0
- package/docs/agents.md +4 -0
- package/docs/app-backend.md +3 -1
- package/package.json +1 -1
- package/src/config.ts +16 -6
- package/src/dev-server.ts +16 -4
- package/src/runtime/session.ts +2 -0
- package/src/runtime/tmux.ts +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
- A model pin follows its agent: change `chat.agent` (or `agents.builder`) and the conversation saved on the old agent is retired on the next start instead of resumed with the new agent's pin. It stays readable in `.golem/conversations.json`, marked, and the next message opens a fresh conversation on the configured agent.
|
|
6
|
+
- `chat: { provider: 'tmux', sandbox: 'none' }` launches the chat agent on the builder's bypass profile, for boxes where the CLI's own sandbox cannot start.
|
|
7
|
+
|
|
8
|
+
## 0.2.2
|
|
9
|
+
|
|
10
|
+
- An app pins the model its terminal agents run on: `chat: { provider: 'tmux', agent, model }` for normal-mode chat and `agents.builderModel` for the builder. The pin rides the launch args (`-m` for Codex, `--model` for Claude Code) and is replayed on resume.
|
|
11
|
+
|
|
3
12
|
## 0.2.1
|
|
4
13
|
|
|
5
14
|
- `./golem` runs on a fresh `pnpm install`: the installed entry resolves `tsx` from golem-kit's own tree instead of the app's `node_modules/.bin`.
|
package/docs/agents.md
CHANGED
|
@@ -18,6 +18,7 @@ export default {
|
|
|
18
18
|
title: 'Field Notes',
|
|
19
19
|
agents: {
|
|
20
20
|
builder: 'claude', // or 'codex': the agent build mode starts with
|
|
21
|
+
builderModel: 'claude-opus-5-5', // pins that CLI's model
|
|
21
22
|
ordinary: {
|
|
22
23
|
backend: 'anthropic',
|
|
23
24
|
operations: ['records.list', 'records.get', 'records.update', 'notes.archive'],
|
|
@@ -29,6 +30,9 @@ export default {
|
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
- `builder` is the default choice in build mode. A person's own pick in the browser still wins and is remembered.
|
|
33
|
+
- `builderModel` pins the builder CLI's model, in that CLI's own spelling (`--model` for Claude Code, `-m` for Codex). A terminal chat pins its own the same way: `chat: { provider: 'tmux', agent: 'codex', model: 'gpt-6-luna' }`. Both survive a resume. Left out, each CLI picks its default.
|
|
34
|
+
- Change `chat.agent` (or `agents.builder`) and the conversation saved on the old agent is retired on the next server start, so its pin never follows it: it stays readable in `.golem/conversations.json`, marked `retired`, and the next message opens a fresh conversation on the configured agent.
|
|
35
|
+
- `chat.sandbox` picks the terminal chat's launch profile: `'read-only'` (the default) is the CLI's own read-only sandbox, `'none'` is the builder's bypass profile. Use `'none'` where the sandbox cannot start, e.g. codex's `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted` under `kernel.apparmor_restrict_unprivileged_userns=1`.
|
|
32
36
|
- `ordinary` turns on the **Start a chat** button. Leave it out and there is no ordinary chat.
|
|
33
37
|
- `ordinary.model` defaults to `claude-opus-5`.
|
|
34
38
|
- `golem.config.ts` is bundled into the browser. Keep the API key in `.env.local` or the server environment, never in this file.
|
package/docs/app-backend.md
CHANGED
|
@@ -94,7 +94,9 @@ type Model = { extract<S extends z.ZodType>(request: { schema: S; text: string;
|
|
|
94
94
|
const meeting = await model.extract({ schema: z.object({ date: z.string(), attendees: z.array(z.string()) }), text, instructions: 'Leave a field empty rather than guessing.' })
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
The app names the shape it wants and never which model answered.
|
|
97
|
+
The app names the shape it wants and never which model answered. Which runtime does is the app's
|
|
98
|
+
`model: { runtime, name }` — `{ runtime: 'codex', name: 'gpt-6-luna' }` is the cheap everyday pick
|
|
99
|
+
for extraction and scheduled jobs. Absent, it is the local Claude
|
|
98
100
|
Code CLI — the runtime build mode already depends on, and the one that asks for no API key; the prompt
|
|
99
101
|
goes in on stdin and the call runs in a temporary directory, so neither `ps` nor the app's folder
|
|
100
102
|
is part of it. When no model can answer — the runtime is missing, times out, or gives nothing the
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -12,14 +12,21 @@ export type ModelConfig = { runtime: 'claude' | 'codex'; name?: string }
|
|
|
12
12
|
* terminal agent in the `chat` window of the app's tmux session, briefed from `docs/chat.md`.
|
|
13
13
|
* `roles` restricts chat to those account roles; absent, anyone signed in may chat.
|
|
14
14
|
*/
|
|
15
|
-
export type ChatConfig = ({ provider: 'anthropic' } | { provider: 'tmux'; agent?: 'codex' | 'claude' }) & { roles?: string[] }
|
|
15
|
+
export type ChatConfig = ({ provider: 'anthropic' } | { provider: 'tmux'; agent?: 'codex' | 'claude'; model?: string; sandbox?: 'read-only' | 'none' }) & { roles?: string[] }
|
|
16
|
+
/**
|
|
17
|
+
* The terminal chat's launch profile: the CLI's own read-only sandbox, or — where that sandbox cannot start
|
|
18
|
+
* (codex's bwrap under `kernel.apparmor_restrict_unprivileged_userns=1`) — the builder's bypass profile.
|
|
19
|
+
*/
|
|
20
|
+
export const chatPermissions = (chat: ChatConfig | undefined): 'bypass' | 'readonly' =>
|
|
21
|
+
chat?.provider === 'tmux' && chat.sandbox === 'none' ? 'bypass' : 'readonly'
|
|
22
|
+
|
|
16
23
|
/** `brain: true` serves the app's `brain/` folder read-only and mounts the Brain reader beside the app. */
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* `builder` is the agent build mode starts with. `ordinary` turns on everyday chat: an API agent
|
|
20
27
|
* whose only tools are the listed app operations, run as the person chatting.
|
|
21
28
|
*/
|
|
22
|
-
export type AgentsConfig = { builder?: 'codex' | 'claude'; ordinary?: OrdinaryAgentConfig }
|
|
29
|
+
export type AgentsConfig = { builder?: 'codex' | 'claude'; builderModel?: string; ordinary?: OrdinaryAgentConfig }
|
|
23
30
|
export type OrdinaryAgentConfig = { backend: 'anthropic'; model: string; operations: string[]; collections?: string[]; roots?: string[]; instructions?: string }
|
|
24
31
|
|
|
25
32
|
/** golem-ui's Auth role shape: `manages` roles run accounts and may build; `builder` may build. */
|
|
@@ -70,10 +77,12 @@ export async function loadAppConfig(root = process.cwd()): Promise<AppConfig> {
|
|
|
70
77
|
config.agents = { ...config.agents, ordinary: ordinaryAgent({ backend: 'anthropic', ...rest }) }
|
|
71
78
|
config.chat = { provider, ...chatRoles }
|
|
72
79
|
} else if (provider === 'tmux') {
|
|
73
|
-
const { agent, ...unknown } = rest
|
|
80
|
+
const { agent, model, sandbox, ...unknown } = rest
|
|
74
81
|
if (Object.keys(unknown).length) throw new Error(`golem.config.ts chat has unknown fields: ${Object.keys(unknown).join(', ')}`)
|
|
75
82
|
if (agent !== undefined && agent !== 'codex' && agent !== 'claude') throw new Error("golem.config.ts chat.agent must be 'codex' or 'claude'")
|
|
76
|
-
|
|
83
|
+
if (model !== undefined && (typeof model !== 'string' || !model)) throw new Error('golem.config.ts chat.model must be a nonempty string')
|
|
84
|
+
if (sandbox !== undefined && sandbox !== 'read-only' && sandbox !== 'none') throw new Error("golem.config.ts chat.sandbox must be 'read-only' or 'none'")
|
|
85
|
+
config.chat = { provider, ...(agent ? { agent } : {}), ...(model ? { model: model as string } : {}), ...(sandbox ? { sandbox: sandbox as 'read-only' | 'none' } : {}), ...chatRoles }
|
|
77
86
|
} else throw new Error("golem.config.ts chat.provider must be 'anthropic' or 'tmux'")
|
|
78
87
|
} else if (config.agents?.ordinary) config.chat = { provider: 'anthropic' }
|
|
79
88
|
return config
|
|
@@ -117,10 +126,11 @@ function chatRolesOf(value: unknown, accounts: AccountsConfig | undefined): stri
|
|
|
117
126
|
|
|
118
127
|
function agents(value: unknown): AgentsConfig {
|
|
119
128
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('golem.config.ts agents must be an object')
|
|
120
|
-
const { builder, ordinary, ...unknown } = value as Record<string, unknown>
|
|
129
|
+
const { builder, builderModel, ordinary, ...unknown } = value as Record<string, unknown>
|
|
121
130
|
if (Object.keys(unknown).length) throw new Error(`golem.config.ts agents has unknown fields: ${Object.keys(unknown).join(', ')}`)
|
|
122
131
|
if (builder !== undefined && builder !== 'codex' && builder !== 'claude') throw new Error("golem.config.ts agents.builder must be 'codex' or 'claude'")
|
|
123
|
-
|
|
132
|
+
if (builderModel !== undefined && (typeof builderModel !== 'string' || !builderModel)) throw new Error('golem.config.ts agents.builderModel must be a nonempty string')
|
|
133
|
+
return { ...(builder ? { builder } : {}), ...(builderModel ? { builderModel: builderModel as string } : {}), ...(ordinary === undefined ? {} : { ordinary: ordinaryAgent(ordinary) }) }
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
// Operations whose input or output is raw bytes, which a chat tool cannot carry.
|
package/src/dev-server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { buildBrowser, rebuild } from './browser-build.ts';
|
|
|
7
7
|
import { createAppBackend, type AppBackend } from './backend/http.ts';
|
|
8
8
|
import { ordinaryChat, type OrdinaryChat } from './chat.ts';
|
|
9
9
|
import { openBrain } from './brain.ts';
|
|
10
|
-
import { serverUrl } from './config.ts';
|
|
10
|
+
import { chatPermissions, serverUrl } from './config.ts';
|
|
11
11
|
import { discoverAgents, runtimeState, type AgentName } from './runtime/discovery.ts';
|
|
12
12
|
import { SessionManager, type Session, type SessionBackend, type SessionSnapshot } from './runtime/session.ts';
|
|
13
13
|
import { TmuxBackend, chatInstructions, type HarnessRef } from './runtime/tmux.ts';
|
|
@@ -45,11 +45,14 @@ export async function startDevServer(
|
|
|
45
45
|
// A new session needs a runnable CLI; a restored one keeps its ref and resumes on its next message.
|
|
46
46
|
createBackend ??= async (backend, ref, buildMode = true) => {
|
|
47
47
|
if (!ref && !(await discoverAgents()).some((found) => found.agent === backend && found.runnable)) throw new Error(`${backend} is not runnable here`);
|
|
48
|
-
|
|
48
|
+
const model = buildMode ? app.config.agents?.builderModel : app.config.chat?.provider === 'tmux' ? app.config.chat.model : undefined;
|
|
49
|
+
return new TmuxBackend(appRoot, backend, ref, { stateDir: join(stateDirectory, 'harness'), api: serverUrl(host, port), window: buildMode ? 'builder' : 'chat', ...(model ? { model } : {}), ...(buildMode ? {} : { instructions: chatInstructions(appRoot), permissions: chatPermissions(app.config.chat) }) });
|
|
49
50
|
};
|
|
50
51
|
const builder = await builderFlag(stateDirectory);
|
|
51
52
|
const state = new ConversationState(stateDirectory);
|
|
52
|
-
|
|
53
|
+
// Retired conversations are no longer live, but they stay in the file: `save` writes them back beside the rest.
|
|
54
|
+
let retired: SessionSnapshot[] = [];
|
|
55
|
+
const sessions = new SessionManager((snapshots) => state.save([...retired, ...snapshots]));
|
|
53
56
|
const app = await createAppBackend(appRoot, join(stateDirectory, 'data'));
|
|
54
57
|
const chat = ordinaryChat(app);
|
|
55
58
|
const brain = app.config.brain ? openBrain(join(appRoot, 'brain')) : undefined;
|
|
@@ -59,7 +62,16 @@ export async function startDevServer(
|
|
|
59
62
|
owns: (conversation, owner) => { const session = sessions.get(conversation); return session?.backend === 'anthropic' && session.owner === owner; },
|
|
60
63
|
});
|
|
61
64
|
// Restored conversations wait for their next message; nothing is re-run.
|
|
62
|
-
const
|
|
65
|
+
const saved = await state.load();
|
|
66
|
+
// A pin follows its agent: when the app changes `chat.agent` (or `agents.builder`), the conversation saved
|
|
67
|
+
// on the old agent is retired rather than resumed, so the new agent's model never reaches the old CLI. It
|
|
68
|
+
// stays readable in the file, marked; the browser finds no conversation and starts one on the new agent.
|
|
69
|
+
const agentOf = (buildMode: boolean) => buildMode ? app.config.agents?.builder : app.config.chat?.provider === 'tmux' ? app.config.chat.agent : undefined;
|
|
70
|
+
const stale = saved.filter((snapshot) => !snapshot.retired && snapshot.backend !== 'anthropic'
|
|
71
|
+
&& agentOf(snapshot.buildMode) !== undefined && agentOf(snapshot.buildMode) !== snapshot.backend);
|
|
72
|
+
for (const snapshot of stale) console.log(`Retired the ${snapshot.buildMode ? 'builder' : 'chat'} conversation on ${snapshot.backend}: this app now runs ${agentOf(snapshot.buildMode)}.`);
|
|
73
|
+
retired = saved.filter((snapshot) => snapshot.retired || stale.includes(snapshot)).map((snapshot) => ({ ...snapshot, retired: true }));
|
|
74
|
+
const restored = saved.filter((snapshot) => !snapshot.retired && !stale.includes(snapshot));
|
|
63
75
|
const workers = await Promise.all(restored.map((snapshot) => snapshot.backend === 'anthropic'
|
|
64
76
|
? chat.backend(snapshot.transcript)
|
|
65
77
|
: createBackend(snapshot.backend, snapshot.harness as HarnessRef | undefined, snapshot.buildMode)));
|
package/src/runtime/session.ts
CHANGED
|
@@ -392,6 +392,8 @@ export type SessionSnapshot = {
|
|
|
392
392
|
harness?: unknown
|
|
393
393
|
transcript?: unknown
|
|
394
394
|
updatedAt?: string
|
|
395
|
+
/** Set when the app's configured agent no longer matches `backend`: kept in the file, never restored. */
|
|
396
|
+
retired?: boolean
|
|
395
397
|
}
|
|
396
398
|
|
|
397
399
|
export class SessionManager {
|
package/src/runtime/tmux.ts
CHANGED
|
@@ -53,9 +53,10 @@ export const brainInstructions = 'This app has a brain: `brain/` is an Open Know
|
|
|
53
53
|
/**
|
|
54
54
|
* `window` names this agent's window in the app's session (`builder`, `chat`); `instructions` its launch prompt;
|
|
55
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.
|
|
56
|
+
* `./golem say`, nothing else, and refuses rather than prompts. `model` pins the agent's model, in that
|
|
57
|
+
* CLI's own spelling (`gpt-6-luna` for codex, `claude-opus-5-5` for claude).
|
|
57
58
|
*/
|
|
58
|
-
export type TmuxOptions = { harness?: Harness; stateDir?: string; api?: string; window?: string; instructions?: string; permissions?: 'bypass' | 'readonly' }
|
|
59
|
+
export type TmuxOptions = { harness?: Harness; stateDir?: string; api?: string; window?: string; instructions?: string; permissions?: 'bypass' | 'readonly'; model?: string }
|
|
59
60
|
|
|
60
61
|
/** The one tmux session of an app's build mode: `tmux attach -t golem-<app dir>` is always the place to look. */
|
|
61
62
|
export const tmuxSessionName = (cwd: string): string => `golem-${basename(cwd).replace(/[^A-Za-z0-9_-]/g, '-')}`
|
|
@@ -96,7 +97,10 @@ export class TmuxBackend implements SessionBackend {
|
|
|
96
97
|
// codex 0.155: the update prompt at launch would take the typed brief as its answer, the
|
|
97
98
|
// paste-burst fold swallows the first Enter of a long line, and the rate-limit "keep current
|
|
98
99
|
// model" nudge after the first turn eats the first message. All off; replayed on resume.
|
|
99
|
-
extraArgs:
|
|
100
|
+
extraArgs: [
|
|
101
|
+
...(this.agent === 'codex' ? ['-c', 'check_for_update_on_startup=false', '-c', 'disable_paste_burst=true', '-c', 'notice.hide_rate_limit_model_nudge=true'] : []),
|
|
102
|
+
...(this.opts.model ? [this.agent === 'codex' ? '-m' : '--model', this.opts.model] : []),
|
|
103
|
+
],
|
|
100
104
|
}
|
|
101
105
|
try {
|
|
102
106
|
// A ref saved under an older naming (`golem-<uuid>`) comes back in the app's fixed session: only
|