Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/spec/clauses/03-providers-models-and-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ Boundaries:
| [MUS-SPEC-03-722B](03-providers-models-and-accounts.md#mus-spec-03-722b) | Only the replay refusal is retried, and only once; any other 409 reaches the coding client unchanged with its request sent as is. | [`the bridge retries only the replay refusal, only once, and passes every other 409 through unchanged`](../../../packages/agent-app/tests/router-replay-722.test.ts) |
| [MUS-SPEC-03-724A](03-providers-models-and-accounts.md#mus-spec-03-724a) | The first message on the new provider carries a summary of the earlier conversation ahead of the new text and says so in a notice; later messages continue the new provider conversation without it. | [`switching a chat to a model on another provider keeps the conversation: the first message there carries the earlier one`](../../../packages/agent-app/tests/provider-switch-context-724.test.ts) |
| [MUS-SPEC-03-724B](03-providers-models-and-accounts.md#mus-spec-03-724b) | The next message continues the same provider thread with no summary added. | [`another model on the same provider account continues the same provider conversation without a summary`](../../../packages/agent-app/tests/provider-switch-context-724.test.ts) |
| [MUS-SPEC-03-720A](03-providers-models-and-accounts.md#mus-spec-03-720a) | The chat runs in Muster's own coding-client home under its data folder: no thread or thread index is written to the person's home, the person's profile is untouched, and follow-ups continue the same thread. | [`a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |
| [MUS-SPEC-03-720B](03-providers-models-and-accounts.md#mus-spec-03-720b) | A chat started before the change continues on its own thread with its earlier messages, and the person's copy of that thread is not written to again. | [`a chat started before the change continues its own thread with its history, without writing to the person’s home again`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |
| [MUS-SPEC-03-720C](03-providers-models-and-accounts.md#mus-spec-03-720c) | Usage limits recorded in the person's home by their other coding apps still show for that account, the newest reading winning. | [`usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |

### Configured routers

Expand Down Expand Up @@ -500,3 +503,38 @@ Boundaries:
- Environment: Node test runner; the real service with a fake provider adapter; no network.
- Known gaps: Guard for existing behaviour, so it passes on main by design; it keeps the fix from adding a summary where none is needed.
- Evidence: UNVERIFIED

### Muster's own coding-client home

<a id="mus-spec-03-720a"></a>
#### MUS-SPEC-03-720A: A new chat runs in Muster's own home

- Precondition: The person has a coding-client home with a sign-in or profile (here a router profile with an auth helper).
- Action: Start a chat on that profile, send a message, then a follow-up.
- Expected result: The run reaches the router through the person's profile; the thread is stored under `<dataDir>/codex-home/<id>/sessions`, the person's home gets no rollout and no thread index, the profile file is unchanged, and the follow-up continues the same thread.
- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration`
- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
- Known gaps: A ChatGPT sign-in kept only in the Keychain is tied to the home's path, so that route keeps the person's home; Windows without symlink rights copies shared files instead of linking them.
- Evidence: UNVERIFIED

<a id="mus-spec-03-720b"></a>
#### MUS-SPEC-03-720B: A chat from before the change keeps its thread

- Precondition: A chat ran on an earlier build, so its thread is in the person's home.
- Action: Open it on this build and send a message that depends on the earlier one.
- Expected result: The chat keeps its provider thread, the model sees the earlier message, the person's copy of the thread does not grow, and no new thread appears there.
- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a chat started before the change continues its own thread with its history, without writing to the person’s home again`
- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
- Known gaps: None known.
- Evidence: UNVERIFIED

<a id="mus-spec-03-720c"></a>
#### MUS-SPEC-03-720C: Usage limits still show

- Precondition: A chat has run in Muster's own home, and another coding app has since recorded a newer rate-limit reading for the same account in the person's home.
- Action: Read the usage for that account.
- Expected result: The newer reading from the person's home is shown.
- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home`
- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
- Known gaps: None known.
- Evidence: UNVERIFIED
1 change: 1 addition & 0 deletions packages/agent-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to Muster Agent. Each `## <version>` section becomes the not
## Unreleased

- Claude chats on your own router no longer reset to a summary when the router has lost the saved thinking for an earlier tool call. Muster resends that part of the history as plain text, so the chat keeps its full history and thread and carries on. Starting fresh is now a last resort.
- Chats on your coding-client sign-ins and profiles no longer show up in your other coding apps. Muster now keeps its own threads in its data folder while sharing your sign-in, profiles, skills and plugins; existing chats continue where they left off.
- Switching a chat to a model on another provider or account no longer loses the conversation. The first message on the new model now carries a summary of the earlier messages, as forks and compaction already do, and the chat says so.

## 0.3.15
Expand Down
82 changes: 82 additions & 0 deletions packages/agent-app/src/runtime/codex-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* #720: Muster runs the coding client with its own home, so the threads it creates never show up in the person's other
* coding apps that read the same home. The person's home stays the source of truth for everything the client should
* share: sign-in, configuration, profiles, model catalogs, skills, plugins, rules and helpers are linked in. Threads,
* the thread index, logs, history and memories stay private to Muster's home.
*/
import {createHash} from 'node:crypto';
import {copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, statSync, symlinkSync, unlinkSync, type Dirent} from 'node:fs';
import {join, resolve} from 'node:path';

const SHARED = new Set(['auth.json', 'config.toml', 'models_cache.json', 'model-catalogs', 'skills', 'plugins', 'rules', 'prompts',
'AGENTS.md', 'AGENTS.override.md', 'helpers', 'computer-use', 'node_repl', 'packages', 'vendor_imports']);
/** Whether an entry of the person's home is shared with Muster's home (profiles are `<name>.config.toml`). */
export const sharedCodexEntry = (name: string): boolean => SHARED.has(name) || /^[A-Za-z0-9_.-]{1,120}\.config\.toml$/.test(name);

/** Muster's own home for one of the person's homes (the default one or an extra account). */
export function musterCodexHome(dataDir: string, sourceHome: string): string {
return join(dataDir, 'codex-home', createHash('sha256').update(resolve(sourceHome)).digest('hex').slice(0, 12));
}

/** Links the shared entries of the person's home into Muster's home. Run before each launch, so profiles added since
* appear and a link the client replaced with a file (a save that renamed over it) is folded back: the newer copy
* goes to the person's home and the link is restored. Where links can't be made (Windows without the privilege),
* directories use junctions and files are copied both ways by age. */
export function prepareMusterCodexHome(sourceHome: string, home: string): void {
mkdirSync(home, {recursive: true, mode: 0o700});
let entries: Dirent[] = [];
try { entries = readdirSync(sourceHome, {withFileTypes: true}); } catch { return; }
for (const entry of entries) {
if (!sharedCodexEntry(entry.name)) continue;
const target = join(sourceHome, entry.name), link = join(home, entry.name);
let directory = entry.isDirectory();
try { if (entry.isSymbolicLink()) directory = statSync(target).isDirectory(); } catch { continue; }
let current: ReturnType<typeof lstatSync> | undefined;
try { current = lstatSync(link); } catch { current = undefined; }
try {
if (current?.isSymbolicLink()) { if (readlinkSync(link) === target) continue; unlinkSync(link); }
else if (current?.isFile()) {
if (current.mtimeMs > statSync(target).mtimeMs) copyFileSync(link, target);
unlinkSync(link);
} else if (current) continue;
try { symlinkSync(target, link, directory ? 'junction' : 'file'); }
catch { if (!directory) copyFileSync(target, link); }
} catch { /* this entry stays as it is; the client reports what it misses */ }
}
}

const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const adopted = new Set<string>();
const subdirs = (dir: string) => { try { return readdirSync(dir, {withFileTypes: true}).filter(entry => entry.isDirectory()).map(entry => entry.name).sort().reverse(); } catch { return []; } };
const rolloutIn = (dir: string, suffix: string) => { try { return readdirSync(dir).find(name => name.startsWith('rollout-') && name.endsWith(suffix)); } catch { return undefined; } };

/** Where a thread's rollout lives under a home: `sessions/YYYY/MM/DD/`, or flat in `archived_sessions/`. */
function findRollout(home: string, threadId: string): {path: string; dated: string[]} | undefined {
const suffix = `-${threadId}.jsonl`, sessions = join(home, 'sessions');
for (const year of subdirs(sessions)) for (const month of subdirs(join(sessions, year))) for (const day of subdirs(join(sessions, year, month))) {
const name = rolloutIn(join(sessions, year, month, day), suffix);
if (name) return {path: join(sessions, year, month, day, name), dated: [year, month, day, name]};
}
const archived = rolloutIn(join(home, 'archived_sessions'), suffix);
const date = archived?.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T/);
return archived && date ? {path: join(home, 'archived_sessions', archived), dated: [date[1]!, date[2]!, date[3]!, archived]} : undefined;
}

/** A chat started before #720 has its thread in the person's home: copy it into Muster's home once, so the chat
* continues there with its whole history and the person's copy is never written to again. */
export function adoptCodexThread(sourceHome: string, home: string, threadId: string | undefined): void {
if (!threadId || !THREAD_ID.test(threadId)) return;
const key = `${home}\0${threadId}`;
if (adopted.has(key)) return;
if (!findRollout(home, threadId)) {
const found = findRollout(sourceHome, threadId);
if (found) try {
const [year, month, day, name] = found.dated as [string, string, string, string];
const folder = join(home, 'sessions', year, month, day);
mkdirSync(folder, {recursive: true, mode: 0o700});
if (!existsSync(join(folder, name))) copyFileSync(found.path, join(folder, name));
} catch { return; }
}
if (adopted.size >= 4096) adopted.clear();
adopted.add(key);
}
10 changes: 7 additions & 3 deletions packages/agent-app/src/runtime/provider-instances.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {musterCodexHome} from './codex-home.ts';
import {createRequire} from 'node:module';
import {createHash} from 'node:crypto';
import * as nodeFs from 'node:fs';
Expand Down Expand Up @@ -511,12 +512,15 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc
const label=duplicate?`${names[index]} (${route.profile})`:names[index]!;
const name=account?`${label} · ${account.label??email??`account ${suffix.slice(1,7)}`}`:label;
const catalogPath=route.catalogPath&&isAbsolute(route.catalogPath)?route.catalogPath:undefined;
const childEnv:Record<string,string>={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:codexHome,...(cli?{MUSTER_CODEX_COMMAND:cli}:{}),
// #720: chats run in Muster's own home (shared sign-in and configuration, private threads). A sign-in kept outside
// auth.json (the Keychain) is tied to the home's path, so that route keeps the person's home.
const ownHome=registeredDataDir&&env.MUSTER_SHARED_CODEX_HOME!=='1'&&(route.kind!=='chatgpt'||Boolean(auth))?musterCodexHome(registeredDataDir,codexHome):undefined;
const childEnv:Record<string,string>={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:ownHome??codexHome,...(ownHome?{MUSTER_CODEX_SOURCE_HOME:codexHome}:{}),...(cli?{MUSTER_CODEX_COMMAND:cli}:{}),
...(route.profile?{MUSTER_CODEX_PROFILE:route.profile}:{MUSTER_CODEX_PROVIDER:route.modelProvider,...(catalogPath?{MUSTER_CODEX_CATALOG:catalogPath}:{})})};
const codex={modelProvider:route.modelProvider,kind:route.kind,...(route.profile?{profile:route.profile}:{}),...(account?{account:suffix.slice(1)}:{})} satisfies NonNullable<ProviderInfo['codex']>;
const source=route.profile?`Codex profile ${route.profile}.config.toml${account?` in ${account.codexHome}`:''}`:route.kind==='chatgpt'?`Codex sign-in${account?` at ${account.codexHome}`:''}`:`[model_providers.${route.modelProvider}] in Codex config.toml${account?` at ${account.codexHome}`:''}`;
const base={id,name,driver:'codex-app-server',codex,identityMasked:route.kind==='gateway'?'Gateway · account hidden':email??'ChatGPT account · hidden',models:[],available:false,source,...(route.baseUrl?{endpoint:route.baseUrl}:{})} satisfies ProviderInfo;
const sessionsRoot=join(codexHome,'sessions');
const sessionsRoot=join(ownHome??codexHome,'sessions');
const fail=(error:string,status:'configured'|'error'='configured'):ProviderInstance=>({info:{...base,status,error,detail:`${error} No provider fallback will be used. ${mcpDetail(mcp)}`},command,env:childEnv,sessionsRoot});
if(route.invalid)return fail(`${route.profile}.config.toml did not validate: ${route.invalid}`,'error');
try {fs.accessSync(command,constants.X_OK);} catch {return fail('Muster’s bundled Codex launcher is missing. Reinstall Muster.','error');}
Expand Down Expand Up @@ -598,5 +602,5 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc
/** The Codex route behind a provider id, from the current instance list; undefined for non-Codex routes. */
export function codexRouteFor(id:string,options:ProviderInstanceOptions={}):{codex:NonNullable<ProviderInfo['codex']>;codexHome:string;sessionsRoot:string;instance:ProviderInstance}|undefined {
const instance=configuredProviderInstances(options).find(row=>row.info.id===id&&row.info.codex);
return instance?{codex:instance.info.codex!,codexHome:instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined;
return instance?{codex:instance.info.codex!,codexHome:instance.env.MUSTER_CODEX_SOURCE_HOME??instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined;
}
22 changes: 20 additions & 2 deletions packages/agent-app/src/runtime/provider-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* - session-log: the newest `rate_limits` line in a Codex rollout file under sessionsRoot.
* Percentages are clamped to 0-100; anything malformed is ignored, never shown as 0%.
*/
import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { closeSync, openSync, readSync, readdirSync, readlinkSync, statSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import type { ProviderUsage, ProviderUsageWindow } from '../shared/domains/providers-protocol.ts';

const record = (value: unknown): Record<string, unknown> | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
Expand Down Expand Up @@ -73,6 +73,24 @@ export function recentRollouts(sessionsRoot: string, limit = 12): string[] {
}
/** The newest rate-limit snapshot per model provider id (`session_meta.model_provider`) across recent rollouts. */
export function sessionRateLimits(sessionsRoot: string): Map<string, ProviderUsage> {
const found = rootRateLimits(sessionsRoot);
// #720: Muster's own home links its sign-in to the person's home; other apps' recent rollouts there carry the same
// account's limits, so the newest reading from either side wins.
const source = linkedSourceHome(dirname(sessionsRoot));
if (source) for (const [provider, usage] of rootRateLimits(join(source, 'sessions'))) {
const mine = found.get(provider);
if (!mine || usage.updatedAt > mine.updatedAt) found.set(provider, usage);
}
return found;
}
/** The person's home behind Muster's own home, read from the link Muster keeps for its sign-in or configuration. */
function linkedSourceHome(home: string): string | undefined {
for (const name of ['auth.json', 'config.toml']) {
try { const target = readlinkSync(join(home, name)); const path = isAbsolute(target) ? target : resolve(home, target); return dirname(path); } catch { /* not a link */ }
}
return undefined;
}
function rootRateLimits(sessionsRoot: string): Map<string, ProviderUsage> {
const found = new Map<string, ProviderUsage>();
for (const file of recentRollouts(sessionsRoot)) {
let head = '', tail = '';
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-app/src/runtime/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {ASK_USER_METHOD, ASK_USER_RULE} from './ask-user.ts';
import type {McpServerSpec} from './adapters/types.ts';
import type {RunnableAdapter} from './adapters/types.ts';
import {CLAUDE_LANE_DRIVER} from '../shared/provider-lanes.ts';
import {adoptCodexThread, prepareMusterCodexHome} from './codex-home.ts';
import {claudeLaneAllowed, LANE_DROPPED_SERVERS, withoutUnavailableToolNotes} from './claude-lane.ts';
import {SANDBOX_MCP_SERVER} from '../shared/sandbox-routes.ts';
import {routeSandboxNote} from './sandbox-note.ts';
Expand Down Expand Up @@ -415,6 +416,8 @@ export function createProviderAdapter(options: { core?: CoreClient; available?:
const developerInstructions = runInstructions(input.chat.mode, input.developerInstructions, true, providerAccessPolicy(input.chat).permissionMode);
const split = splitSecretOverrides(input.configOverrides);
const extraOverrides = Object.entries(split.args).filter(([name]) => /^[A-Za-z0-9_.-]{1,128}$/.test(name) && !/^(sandbox|approval_policy)/.test(name)).map(([name, value]) => `${name}=${JSON.stringify(value)}`);
// #720: link the person's shared configuration into Muster's own home, and bring over a thread from before.
if (route.env.MUSTER_CODEX_SOURCE_HOME && route.env.CODEX_HOME) { prepareMusterCodexHome(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME); adoptCodexThread(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME, resumeThread); }
const launch = {
cwd: input.cwd, command: route.command, model, reasoning,
env: { MUSTER_PROVIDER_NODE: node.node, ...node.env, ...route.env }, transportOwner: session.owner, cacheKey: session.key,
Expand Down
Loading
Loading