golem-kit 0.2.2 → 0.2.4

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4
4
+
5
+ - Chat and builder no longer fight over one Stop hook: the turn-end relay asks tmux which window it ran in instead of trusting the key baked in when it was installed, so a chat message sent after the builder was opened gets its reply and the composer unlocks. `installHooks` also keeps exactly one entry for itself, matched by script name, so a source-pin change between releases replaces it instead of stacking another.
6
+
7
+ ## 0.2.3
8
+
9
+ - 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.
10
+ - `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.
11
+
3
12
  ## 0.2.2
4
13
 
5
14
  - 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.
package/docs/agents.md CHANGED
@@ -31,6 +31,8 @@ export default {
31
31
 
32
32
  - `builder` is the default choice in build mode. A person's own pick in the browser still wins and is remembered.
33
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`.
34
36
  - `ordinary` turns on the **Start a chat** button. Leave it out and there is no ordinary chat.
35
37
  - `ordinary.model` defaults to `claude-opus-5`.
36
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "golem-kit",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Starter kit and local CLI for Golem applications.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/config.ts CHANGED
@@ -12,7 +12,14 @@ 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'; model?: string }) & { 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
  /**
@@ -70,11 +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, model, ...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')
77
- config.chat = { provider, ...(agent ? { agent } : {}), ...(model ? { model: model as string } : {}), ...chatRoles }
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 }
78
86
  } else throw new Error("golem.config.ts chat.provider must be 'anthropic' or 'tmux'")
79
87
  } else if (config.agents?.ordinary) config.chat = { provider: 'anthropic' }
80
88
  return config
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';
@@ -46,11 +46,13 @@ export async function startDevServer(
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: 'readonly' }) });
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) }) });
50
50
  };
51
51
  const builder = await builderFlag(stateDirectory);
52
52
  const state = new ConversationState(stateDirectory);
53
- const sessions = new SessionManager((snapshots) => state.save(snapshots));
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]));
54
56
  const app = await createAppBackend(appRoot, join(stateDirectory, 'data'));
55
57
  const chat = ordinaryChat(app);
56
58
  const brain = app.config.brain ? openBrain(join(appRoot, 'brain')) : undefined;
@@ -60,7 +62,16 @@ export async function startDevServer(
60
62
  owns: (conversation, owner) => { const session = sessions.get(conversation); return session?.backend === 'anthropic' && session.owner === owner; },
61
63
  });
62
64
  // Restored conversations wait for their next message; nothing is re-run.
63
- const restored = await state.load();
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));
64
75
  const workers = await Promise.all(restored.map((snapshot) => snapshot.backend === 'anthropic'
65
76
  ? chat.backend(snapshot.transcript)
66
77
  : createBackend(snapshot.backend, snapshot.harness as HarnessRef | undefined, snapshot.buildMode)));
@@ -161,18 +161,19 @@ async function installHooks(cwd, session, stateDir, callbackUrl) {
161
161
  const command = ['node', s.shellQuote(HOOK_SCRIPT), s.shellQuote(stateDir), s.shellQuote(session)]
162
162
  .concat(callbackUrl ? [s.shellQuote(callbackUrl)] : [])
163
163
  .join(' ');
164
+ const hookName = path.basename(HOOK_SCRIPT);
164
165
  mergeLocalSettings(cwd, (settings) => {
165
166
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
166
167
  if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
167
- const ours = settings.hooks.Stop.some((m) =>
168
- Array.isArray(m.hooks) && m.hooks.some((h) => h.command === command));
169
- if (!ours) {
170
- // Drop stale bc hook entries (e.g. a previous session in this cwd) first.
171
- settings.hooks.Stop = settings.hooks.Stop.filter((m) =>
172
- !(Array.isArray(m.hooks) && m.hooks.some((h) =>
173
- typeof h.command === 'string' && h.command.includes(HOOK_SCRIPT))));
174
- settings.hooks.Stop.push({ hooks: [{ type: 'command', command }] });
175
- }
168
+ // Exactly ONE entry for our script, always. Matched on the basename, not the
169
+ // full path: a source pin (or an npx cache) moves the script between releases,
170
+ // and a path match let one entry per path pile up — every turn end then wrote
171
+ // every stale key's file. The hook resolves its own key at run time, so the
172
+ // single surviving entry serves every window in this cwd.
173
+ settings.hooks.Stop = settings.hooks.Stop.filter((m) =>
174
+ !(Array.isArray(m.hooks) && m.hooks.some((h) =>
175
+ typeof h.command === 'string' && h.command.includes(hookName))));
176
+ settings.hooks.Stop.push({ hooks: [{ type: 'command', command }] });
176
177
  });
177
178
  await excludeLocalSettings(cwd);
178
179
  }
@@ -22,18 +22,33 @@ const fs = require('node:fs');
22
22
  const path = require('node:path');
23
23
  const { execFileSync } = require('node:child_process');
24
24
 
25
- // The hook runs inside the agent's own pane, so its tmux session identifies
26
- // the session exactly (the server attributes lieutenant turn-ends by it).
25
+ // The hook runs inside the agent's own pane, so tmux identifies it exactly.
27
26
  // Empty when not under tmux; never fails the hook when tmux is absent.
28
- function tmuxSession() {
27
+ // golem: '#S:#W', and -t $TMUX_PANE, because a bare display-message answers for
28
+ // the session's ACTIVE window — from the builder's pane, while the chat window
29
+ // was current, it said ':chat'. The pane id is the only self-reference a hook
30
+ // running outside the active window can trust.
31
+ function tmuxPane() {
29
32
  if (!process.env.TMUX) return '';
33
+ const target = process.env.TMUX_PANE ? ['-t', process.env.TMUX_PANE] : [];
30
34
  try {
31
- return execFileSync('tmux', ['display-message', '-p', '#S'], { encoding: 'utf8' }).trim();
35
+ return execFileSync('tmux', ['display-message', '-p'].concat(target, ['#S:#W']), { encoding: 'utf8' }).trim();
32
36
  } catch {
33
37
  return '';
34
38
  }
35
39
  }
36
40
 
41
+ // golem: runtimeKey(argvKey) — which agent's turn just ended.
42
+ // Claude Code keeps ONE Stop hook per cwd, and window-granular agents (the
43
+ // builder and the chat) share a cwd: the key baked into argv at install time is
44
+ // whichever spawned last, so the other one's turn ends would land in the wrong
45
+ // file and its send() would wait forever. Ask tmux instead. Session-granular
46
+ // agents own their whole session (no ':' in their key) and keep the argv key.
47
+ function runtimeKey(argvKey) {
48
+ if (!argvKey.includes(':')) return argvKey;
49
+ return tmuxPane() || argvKey;
50
+ }
51
+
37
52
  function readStdin() {
38
53
  return new Promise((resolve) => {
39
54
  let data = '';
@@ -53,7 +68,7 @@ function readStdin() {
53
68
 
54
69
  async function main() {
55
70
  const stateDir = process.argv[2];
56
- const session = process.argv[3];
71
+ const session = runtimeKey(process.argv[3] || '');
57
72
  const url = process.argv[4] || process.env.BC_TURNEND_URL || '';
58
73
  if (!stateDir || !session) return;
59
74
 
@@ -70,7 +85,7 @@ async function main() {
70
85
  event: payload.hook_event_name || 'Stop',
71
86
  session_id: payload.session_id || null,
72
87
  cwd: payload.cwd || null,
73
- tmux_session: tmuxSession(),
88
+ tmux_session: tmuxPane().split(':')[0],
74
89
  };
75
90
  // What the agent last said, when the harness hands it over — the stall
76
91
  // alert quotes it so the board knows what a silent worker was waiting on.
@@ -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 {