Skip to content
Draft
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
33 changes: 33 additions & 0 deletions docs/managed-workspace-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,39 @@ Do not add external-Pi version probing or upgrade UX to preserve flags used by
the packaged runtime. Compatibility for the packaged app is maintained by
pinning and upgrading the bundled Pi with the OpenAlice release.

### Pi and OpenCode context limits

Workspace provider overrides write the model context limit into both runtime
formats: `provider.models.<id>.limit.context` for OpenCode and
`providers.workspace.models[].contextWindow` for Pi. Unknown custom and local
models default to **256K**, not 1M. Larger values remain explicit user choices;
the provider connection probe cannot prove that a local backend has enough
memory for a future long prefill, and some APIs apply a higher billing tier
above 256K.

**Settings → AI Provider → New Workspace defaults** owns the user-level
creation preference. Each agent can select the vault credential copied into a
new Workspace; Pi and OpenCode also select the context limit, with 256K as the
visible default. The declaration is stored under
`workspaceCredentialDefaults` in `data/config/ai-provider-manager.json`.
Migration `0023_workspace_default_context_window` makes 256K explicit for
existing Pi/OpenCode declarations. A template's `agentCredentials` entry still
wins for that agent when a template intentionally pins provider state.

Creation defaults affect only future Workspaces. After creation, the Workspace
config file is the source of truth for its model-specific choice. Re-selecting
the same vault credential and model must preserve the existing Workspace
context limit. Selecting a different credential or model starts from the
conservative default instead of carrying an unrelated 1M setting across
providers.

Pi and OpenCode load provider state at process startup. Saving a new context
limit does not resize a running process: the UI must name affected running
Sessions and tell the user to pause and resume them. WebPi follows new output
only while the reader is already near the transcript tail; polling, retries,
and streaming updates must never steal the scroll position from someone
reading older messages.

### Codex interactive permissions

OpenAlice launches interactive Codex TUI sessions with explicit
Expand Down
11 changes: 9 additions & 2 deletions src/core/config-workspace-cred-defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,22 @@ describe('workspace credential defaults', () => {
it('round-trips a per-agent map and keeps the optional model', async () => {
const config = await loadConfigModule()
await config.writeWorkspaceCredentialDefaults({
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' },
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 512_000 },
pi: { credentialSlug: 'anthropic-1' },
})
expect(await config.readWorkspaceCredentialDefaults()).toEqual({
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' },
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 512_000 },
pi: { credentialSlug: 'anthropic-1' },
})
})

it('rejects a non-positive context default', async () => {
const config = await loadConfigModule()
await expect(config.writeWorkspaceCredentialDefaults({
pi: { credentialSlug: 'openai-1', contextWindow: 0 },
})).rejects.toThrow()
})

it('drops entries with an empty credentialSlug (the "don\'t seed" choice)', async () => {
const config = await loadConfigModule()
await config.writeWorkspaceCredentialDefaults({
Expand Down
5 changes: 4 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,16 @@ export function credentialWires(cred: Credential): Partial<Record<CredentialWire
* setting. Keyed by agentId (`claude` / `codex` / `opencode` / `pi`).
* `credentialSlug` points into `credentials`; `model` is the optional run model
* (absent ⇒ resolved from the cred's `lastModel`, then the vendor flagship).
* `contextWindow` is meaningful for Pi/OpenCode and is copied into their
* Workspace-local provider config at creation time.
* Structurally a superset-compatible mirror of the workspaces layer's
* `AgentCredentialDecl`, so the creator can merge the two and feed
* `injectWorkspaceCredentials` directly.
*/
export const workspaceCredentialDefaultSchema = z.object({
credentialSlug: z.string(),
model: z.string().optional(),
contextWindow: z.number().int().positive().optional(),
})
export type WorkspaceCredentialDefault = z.infer<typeof workspaceCredentialDefaultSchema>

Expand All @@ -192,7 +195,7 @@ export const aiProviderSchema = z.object({
credentials: z.record(z.string(), credentialSchema).default({}),
/**
* Per-agent default credential seeded into EVERY new workspace at create time
* (agentId → {credentialSlug, model?}). The user-level counterpart to a
* (agentId → {credentialSlug, model?, contextWindow?}). The user-level counterpart to a
* template's `agentCredentials`: set a default cred per agent once and skip the
* per-workspace AI-config modal on each launch. References slugs in
* `credentials`; a dangling slug is loud-skipped at injection, never fatal.
Expand Down
2 changes: 2 additions & 0 deletions src/core/workspace-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Conservative context limit copied into new Pi/OpenCode Workspace configs. */
export const DEFAULT_WORKSPACE_CONTEXT_WINDOW = 256_000
64 changes: 64 additions & 0 deletions src/migrations/0023_workspace_default_context_window.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'

import {
addWorkspaceDefaultContextWindows,
migration,
} from './0023_workspace_default_context_window/index.js'
import type { MigrationContext } from './types.js'

describe('0023 Workspace default context migration', () => {
it('adds 256K to existing Pi/OpenCode defaults without touching other provider state', () => {
const raw = {
credentials: { 'openai-1': { vendor: 'openai' } },
workspaceCredentialDefaults: {
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' },
pi: { credentialSlug: 'openai-1', contextWindow: 128_000 },
claude: { credentialSlug: 'anthropic-1' },
},
}

expect(addWorkspaceDefaultContextWindows(raw)).toEqual({
changed: true,
value: {
...raw,
workspaceCredentialDefaults: {
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 },
pi: { credentialSlug: 'openai-1', contextWindow: 128_000 },
claude: { credentialSlug: 'anthropic-1' },
},
},
})
})

it('repairs invalid stored limits and is idempotent after the first write', async () => {
let value: unknown = {
workspaceCredentialDefaults: {
pi: { credentialSlug: 'openai-1', contextWindow: -1 },
},
}
const writeJson = vi.fn(async (_filename: string, next: unknown) => { value = next })
const ctx: MigrationContext = {
async readJson<T>() { return value as T },
writeJson,
removeJson: vi.fn(async () => undefined),
configDir: () => '/virtual/config',
}

await migration.up(ctx)
await migration.up(ctx)

expect(writeJson).toHaveBeenCalledOnce()
expect(value).toEqual({
workspaceCredentialDefaults: {
pi: { credentialSlug: 'openai-1', contextWindow: 256_000 },
},
})
})

it('does not invent defaults when the setting is absent', () => {
expect(addWorkspaceDefaultContextWindows({ credentials: {} })).toEqual({
changed: false,
value: { credentials: {} },
})
})
})
60 changes: 60 additions & 0 deletions src/migrations/0023_workspace_default_context_window/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { DEFAULT_WORKSPACE_CONTEXT_WINDOW } from '@/core/workspace-defaults.js'
import type { Migration } from '../types.js'

type JsonRecord = Record<string, unknown>

function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export interface WorkspaceDefaultContextMigrationResult {
readonly changed: boolean
readonly value: unknown
}

/** Add an explicit safe context preference to existing Pi/OpenCode defaults. */
export function addWorkspaceDefaultContextWindows(
raw: unknown,
): WorkspaceDefaultContextMigrationResult {
if (!isRecord(raw) || !isRecord(raw.workspaceCredentialDefaults)) {
return { changed: false, value: raw }
}

const nextDefaults: JsonRecord = { ...raw.workspaceCredentialDefaults }
let changed = false
for (const agentId of ['opencode', 'pi']) {
const current = nextDefaults[agentId]
if (!isRecord(current) || typeof current.credentialSlug !== 'string' || !current.credentialSlug) {
continue
}
if (
typeof current.contextWindow === 'number' &&
Number.isInteger(current.contextWindow) &&
current.contextWindow > 0
) {
continue
}
nextDefaults[agentId] = {
...current,
contextWindow: DEFAULT_WORKSPACE_CONTEXT_WINDOW,
}
changed = true
}

return changed
? { changed: true, value: { ...raw, workspaceCredentialDefaults: nextDefaults } }
: { changed: false, value: raw }
}

export const migration: Migration = {
id: '0023_workspace_default_context_window',
appVersion: '0.81.0-beta',
introducedAt: '2026-07-15',
affects: ['ai-provider-manager.json'],
summary: 'Make the 256K Pi/OpenCode context limit explicit in existing new-Workspace credential defaults.',
up: async (ctx) => {
const raw = await ctx.readJson('ai-provider-manager.json')
const result = addWorkspaceDefaultContextWindows(raw)
if (result.changed) await ctx.writeJson('ai-provider-manager.json', result.value)
},
}
1 change: 1 addition & 0 deletions src/migrations/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ Each row corresponds to one migration in `src/migrations/`. The runner applies p
| `0020_headless_issue_trigger` | 0.80.0-beta | 2026-07-12 | workspaces/state/headless-tasks.json | Store the composite Issue trigger separately from a run execution Workspace. |
| `0021_workspace_departure_catalog` | 0.80.0-beta | 2026-07-12 | workspaces/workspaces.json, workspaces/workspaces/*, workspaces/departed-workspaces/*, workspaces/state/workspace-catalog.json | Move unregistered Workspace directories into a durable departed catalog without deleting them. |
| `0022_connector_service_config` | 0.81.0-beta | 2026-07-13 | connectors.json, connector-service.json, ports.json | Move external notifications into sealed Connector Service config and retire legacy Web/MCP-Ask connector meanings. |
| `0023_workspace_default_context_window` | 0.81.0-beta | 2026-07-15 | ai-provider-manager.json | Make the 256K Pi/OpenCode context limit explicit in existing new-Workspace credential defaults. |
4 changes: 3 additions & 1 deletion src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* deletion + Workspace pivot turned the pre-0.40 data shapes over completely, so
* pre-0.40 installs rebuild `data/` rather than migrate. The framework stays for
* future upgrades. Numbering continues FORWARD from the highest id ever shipped
* (next: 0023) — never reuse a retired id, since existing installs' journals
* (next: 0024) — never reuse a retired id, since existing installs' journals
* recorded the old ones.
*/

Expand All @@ -34,6 +34,7 @@ import { migration as migration_0019_issue_session_signatures } from './0019_iss
import { migration as migration_0020_headless_issue_trigger } from './0020_headless_issue_trigger/index.js'
import { migration as migration_0021_workspace_departure_catalog } from './0021_workspace_departure_catalog/index.js'
import { migration as migration_0022_connector_service_config } from './0022_connector_service_config/index.js'
import { migration as migration_0023_workspace_default_context_window } from './0023_workspace_default_context_window/index.js'

export const REGISTRY: Migration[] = [
migration_0008_disable_targetless_cron_jobs,
Expand All @@ -51,4 +52,5 @@ export const REGISTRY: Migration[] = [
migration_0020_headless_issue_trigger,
migration_0021_workspace_departure_catalog,
migration_0022_connector_service_config,
migration_0023_workspace_default_context_window,
]
24 changes: 18 additions & 6 deletions src/webui/routes/config-workspace-defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ describe('GET /workspace-credential-defaults', () => {

const { status, body } = await req(routes, 'GET', '/workspace-credential-defaults')
expect(status).toBe(200)
expect(body!.defaults).toEqual({ opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' } })
expect(body!.defaults).toEqual({
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 },
})

const compat = body!.compatibleByAgent as Record<string, string[]>
// claude speaks anthropic only.
Expand Down Expand Up @@ -219,18 +221,18 @@ describe('GET/PUT /issue-default-agent', () => {
})

describe('PUT /workspace-credential-defaults', () => {
it('replaces the map, keeps optional model, persists via the writer', async () => {
it('replaces the map, keeps optional model/context, and defaults missing context to 256K', async () => {
const routes = createConfigRoutes()
const { status, body } = await req(routes, 'PUT', '/workspace-credential-defaults', {
defaults: {
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' },
pi: { credentialSlug: 'anthropic-1' },
pi: { credentialSlug: 'anthropic-1', contextWindow: 512_000 },
},
})
expect(status).toBe(200)
expect(body!.defaults).toEqual({
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5' },
pi: { credentialSlug: 'anthropic-1' },
opencode: { credentialSlug: 'openai-1', model: 'gpt-5.5', contextWindow: 256_000 },
pi: { credentialSlug: 'anthropic-1', contextWindow: 512_000 },
})
expect(defaultsStore).toEqual(body!.defaults)
})
Expand All @@ -240,7 +242,9 @@ describe('PUT /workspace-credential-defaults', () => {
const { body } = await req(routes, 'PUT', '/workspace-credential-defaults', {
defaults: { opencode: { credentialSlug: 'openai-1' }, pi: { credentialSlug: '' } },
})
expect(body!.defaults).toEqual({ opencode: { credentialSlug: 'openai-1' } })
expect(body!.defaults).toEqual({
opencode: { credentialSlug: 'openai-1', contextWindow: 256_000 },
})
})

it('ignores unknown agent keys (only the four defaultable agents pass through)', async () => {
Expand All @@ -251,6 +255,14 @@ describe('PUT /workspace-credential-defaults', () => {
expect(body!.defaults).toEqual({})
})

it('ignores contextWindow for runtimes that do not expose that Workspace setting', async () => {
const routes = createConfigRoutes()
const { body } = await req(routes, 'PUT', '/workspace-credential-defaults', {
defaults: { claude: { credentialSlug: 'anthropic-1', contextWindow: 1_000_000 } },
})
expect(body!.defaults).toEqual({ claude: { credentialSlug: 'anthropic-1' } })
})

it('clears all defaults on an empty body', async () => {
const routes = createConfigRoutes()
defaultsStore = { opencode: { credentialSlug: 'openai-1' } }
Expand Down
38 changes: 25 additions & 13 deletions src/webui/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ConfigSection, type Credential, type CredentialWireShape,
type WorkspaceCredentialDefault,
} from '../../core/config.js'
import { DEFAULT_WORKSPACE_CONTEXT_WINDOW } from '../../core/workspace-defaults.js'
import { compatibleCredentials } from '../../workspaces/credential-injection.js'

/** Validate a `{ [wireShape]: baseUrl }` body into a typed wires map. */
Expand Down Expand Up @@ -219,6 +220,27 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) {
// user the per-workspace AI-config modal. References the vault above.

const DEFAULTABLE_AGENTS = ['claude', 'codex', 'opencode', 'pi'] as const
const CONTEXT_AGENTS = new Set<string>(['opencode', 'pi'])

const normalizeWorkspaceDefaults = (
incoming: Record<string, WorkspaceCredentialDefault>,
): Record<string, WorkspaceCredentialDefault> => {
const next: Record<string, WorkspaceCredentialDefault> = {}
for (const agent of DEFAULTABLE_AGENTS) {
const def = incoming[agent]
if (!def || typeof def.credentialSlug !== 'string' || !def.credentialSlug) continue
const contextWindow = typeof def.contextWindow === 'number' &&
Number.isInteger(def.contextWindow) && def.contextWindow > 0
? def.contextWindow
: DEFAULT_WORKSPACE_CONTEXT_WINDOW
next[agent] = {
credentialSlug: def.credentialSlug,
...(typeof def.model === 'string' && def.model ? { model: def.model } : {}),
...(CONTEXT_AGENTS.has(agent) ? { contextWindow } : {}),
}
}
return next
}

/**
* GET /workspace-credential-defaults — the current per-agent defaults plus,
Expand All @@ -235,31 +257,21 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) {
for (const agent of DEFAULTABLE_AGENTS) {
compatibleByAgent[agent] = compatibleCredentials(creds, agent).map(([slug]) => slug)
}
return c.json({ defaults, compatibleByAgent })
return c.json({ defaults: normalizeWorkspaceDefaults(defaults), compatibleByAgent })
} catch (err) {
return c.json({ error: String(err) }, 500)
}
})

/**
* PUT /workspace-credential-defaults — replace the whole per-agent map. Body:
* `{ defaults: { [agentId]: { credentialSlug, model? } } }`. An empty/absent
* `{ defaults: { [agentId]: { credentialSlug, model?, contextWindow? } } }`. An empty/absent
* `credentialSlug` for an agent clears its default (handled in the writer).
*/
app.put('/workspace-credential-defaults', async (c) => {
try {
const body = await c.req.json<{ defaults?: Record<string, WorkspaceCredentialDefault> }>()
const incoming = body.defaults ?? {}
const next: Record<string, WorkspaceCredentialDefault> = {}
for (const agent of DEFAULTABLE_AGENTS) {
const def = incoming[agent]
if (def && typeof def.credentialSlug === 'string' && def.credentialSlug) {
next[agent] = {
credentialSlug: def.credentialSlug,
...(typeof def.model === 'string' && def.model ? { model: def.model } : {}),
}
}
}
const next = normalizeWorkspaceDefaults(body.defaults ?? {})
await writeWorkspaceCredentialDefaults(next)
return c.json({ defaults: next })
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/webui/routes/workspaces-quickchat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe('POST /quick-chat — loginless credential injection', () => {
expect(cred.apiKey).toBe('sk-oa');
expect(cred.wireShape).toBe('openai-chat');
expect(cred.model).toBe('gpt-5.5'); // vendor flagship — no lastModel yet
expect(cred.contextWindow).toBe(1_000_000);
expect(cred.contextWindow).toBe(256_000);
// model remembered on the cred for next time
expect(vi.mocked(setCredentialLastModel)).toHaveBeenCalledWith('openai-1', 'gpt-5.5');
expect(spawn).toHaveBeenCalledOnce();
Expand Down
Loading
Loading