mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 03:16:23 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb0394c81 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 |
@@ -0,0 +1,135 @@
|
||||
# Grouped timeline implementation log
|
||||
|
||||
## Delivery agreement
|
||||
|
||||
Work sequentially, one task at a time. Prefer small independently landable PRs
|
||||
targeting `v2`. Combine tasks only when necessary for a working integration.
|
||||
Do not silently change production behavior, even if a difference seems better.
|
||||
Diagnostic parity fixtures/tests and screenshots are local evidence, not intended
|
||||
shipping changes unless explicitly agreed.
|
||||
|
||||
## Ordered tasks and PR boundaries
|
||||
|
||||
| Task | Scope / completion gate | Status | Proposed PR |
|
||||
| --- | --- | --- | --- |
|
||||
| 1. Baseline | Record clean revision, run existing checks, inventory behavior and capture real development TUI | Complete; evidence below | Documentation-only baseline PR (`grouped-timeline`) |
|
||||
| 2. Shared renderers | Extract renderers and shared context from session/index.tsx; preserve layout, subscriptions, defaults and behavior | Not started | `refactor(tui): extract session renderers` |
|
||||
| 3. Generic engine | Recursive grouping paths, seam merge/split, stable identities, idempotent ingestion, cached leaf counts | Not started | Pure engine + focused tests, independently landable if useful |
|
||||
| 4. Production integration | Connect store updates to engine with existing reasoning/exploration rules; establish parity before removing old logic | Not started | Engine integration; combine with task 3 if that avoids an unused module |
|
||||
| 5. Tree rendering/navigation | Recursive rendering, registered message anchors, measured OpenTUI offsets, reveal ancestors | Not started | Combine necessary tree wiring with task 4; remaining navigation work separately |
|
||||
| 6. History/mounting | Complete boundary groups by fetching backward; stable viewport; leaf-based soft budget | Not started | Group-aware history PR |
|
||||
| 7. Experiment | Low/Medium/High configuration, activity/instruction groups and agreed summaries/defaults | Not started | Experimental grouping PR |
|
||||
| 8. End-to-end verification | Full lifecycle, pagination, navigation, replay, long sessions, narrow/wide Drive captures | Not started | Verification accompanies every PR; final integration evidence here |
|
||||
|
||||
Task 1 lands as a documentation-only PR from `grouped-timeline` into `v2`.
|
||||
Task 2 is the next implementation task and will follow in a separate PR.
|
||||
|
||||
## Agreed architecture
|
||||
|
||||
- One production grouping engine; experiment selects rules/presentation, not a separate session route.
|
||||
- Tree is the source of truth and renders recursively; no flat rendering projection.
|
||||
- Existing message types and PartRef conventions remain authoritative.
|
||||
- Two-level grouping is supported from the start: activity -> exploration/reasoning/instructions -> leaves.
|
||||
- Shared production thinking/exploration status presentation remains unchanged for default rules.
|
||||
- Transcript owns message anchors registered by nested renderers. Read actual OpenTUI geometry after layout.
|
||||
- Leaf counts, not group counts, drive a soft mounting budget. Group wrappers count as zero.
|
||||
- Summary membership is independent of the mounted slice and disclosure state.
|
||||
- Fetch backward until the oldest group has a known boundary or history is exhausted.
|
||||
Stage incomplete boundary content; reveal complete groups while preserving the reader's anchor.
|
||||
No new loading indicator. A very long group may require several pages.
|
||||
- Register the feature in the existing permanent Experiments framework; preserve that framework.
|
||||
|
||||
## Task 1: production baseline
|
||||
|
||||
### Revision and environment
|
||||
|
||||
- Worktree: `/root/projects/opencode-grouped-timeline`
|
||||
- Branch: `grouped-timeline`
|
||||
- Clean starting revision: `8f4d7066473ea07d26c5dfc35e46cd9a94e3e292`
|
||||
- Subject: `feat(cli): add command docs and simplify session list`
|
||||
- Created from fetched `origin/v2`; previous verbosity prototype edits remain in the other worktree.
|
||||
- No production source changes made for this task. Git retains the exact baseline source and existing test fixtures.
|
||||
|
||||
### Baseline behavior inventory
|
||||
|
||||
Source: `packages/tui/src/routes/session/rows.ts` and `index.tsx` at the revision above.
|
||||
|
||||
- Flat row union: messages, compaction-queued, parts, reasoning groups, exploration groups, assistant footers, usage.
|
||||
- Exploration membership: case-insensitive read/glob/grep; webfetch/websearch remain standalone.
|
||||
- Adjacent reasoning and exploration can span assistant-message boundaries.
|
||||
- Empty text/reasoning is skipped during history reduction, but still consumes its per-type ordinal.
|
||||
- Tool references use call IDs; text/reasoning references use per-type ordinals.
|
||||
- Visible delimiters finish preceding groups. Terminal/retry assistant footers also finish groups.
|
||||
- Synthetic messages without a nonblank description are skipped.
|
||||
- Running compaction and queued input ordering are explicitly handled, not ordinary assistant parts.
|
||||
- Exploration keeps refs, pending refs and completion state; reasoning keeps refs and completion state.
|
||||
- Current rendering uses `Exploring — ...` / `Explored — ...`; do not substitute prototype colons during extraction.
|
||||
- History loading compensates for scroll-height changes after layout; navigation uses message boundaries.
|
||||
- Initial mounting uses 40 newest rows, with 60-row backfill chunks. Leaf budgeting is future work, not baseline behavior.
|
||||
|
||||
### Automated checks executed
|
||||
|
||||
Both commands run from `packages/tui`:
|
||||
|
||||
```sh
|
||||
bun typecheck
|
||||
bun run test
|
||||
```
|
||||
|
||||
- Typecheck: passed.
|
||||
- Suite: **1352 passed, 4 skipped, 0 failed**, 2 snapshots,
|
||||
107855 assertions across 143 files, 107.88 seconds.
|
||||
- Existing suite emitted resize-listener warnings and teardown/refresh diagnostics
|
||||
(including a session-tab lock ENOENT and an UnexpectedStatus). These occurred
|
||||
before implementation; the suite exited successfully.
|
||||
- Full test output retained locally at
|
||||
`/root/.local/share/opencode/shell/012780c4098d08caa4ea8c479ed0a4690489f38d/sh_08d3aa8b20014fbv25Iyo7BkR8.out`.
|
||||
|
||||
Relevant existing deterministic coverage in `packages/tui/test/cli/tui`:
|
||||
|
||||
| File | Baseline coverage |
|
||||
| --- | --- |
|
||||
| session-rows.test.ts | Group boundaries, cross-message grouping, empty parts, ordinals, synthetic messages, retry footers, compaction ordering |
|
||||
| data.test.tsx | Live classification, queued delivery, failures, retry lifecycle, reconnect, revert, permissions/forms, optimistic admission |
|
||||
| thinking.test.ts | Streamed title extraction and Markdown body preservation |
|
||||
| session-history.test.ts | Prepend anchoring, failed fetch, session switch during fetch, navigation after layout |
|
||||
| message-navigation.test.ts | User-only/all-message navigation, slack, bounds, logical anchors during layout |
|
||||
| inline-tool-wrap-snapshot.test.tsx | Existing tool wrapping snapshots |
|
||||
|
||||
These tests establish the current reference, not proof that the future refactor is equivalent.
|
||||
Each subsequent PR must compare affected outputs against this revision, and add diagnostic
|
||||
coverage where existing fixtures do not exercise the change.
|
||||
|
||||
### Drive evidence
|
||||
|
||||
Local diagnostic script: `/tmp/opencode/grouping-baseline.ts`.
|
||||
It uses a fixed project (`src/example.ts`), simulated reasoning, a real read tool,
|
||||
and a delayed simulated final response to expose exploration's busy state.
|
||||
|
||||
```sh
|
||||
opencode-drive check /tmp/opencode/grouping-baseline.ts
|
||||
opencode-drive start --name grouping-baseline-tools \
|
||||
--dev /root/projects/opencode-grouped-timeline \
|
||||
--script /tmp/opencode/grouping-baseline.ts
|
||||
```
|
||||
|
||||
Both commands passed. Drive shut down its isolated instance on completion.
|
||||
PNG captures were opened and inspected:
|
||||
|
||||
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-exploring.png`: busy exploration, 112x34.
|
||||
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-wide.png`: completed thought/read/text, 112x34.
|
||||
- `/mnt/mail/run-043fc634-307d-46bd-a659-0da2da41521b/generation-0/baseline-narrow.png`: same completed state, 80x24.
|
||||
|
||||
The fixture's content is deterministic; real durations and spinner frames are not.
|
||||
These PNGs are visual references, not byte-identical golden assertions. Future
|
||||
exact layout comparisons need fixed message timestamps and controlled animation.
|
||||
Long-history behavior is covered by the passing existing suite; expanded groups
|
||||
and long-history Drive captures must be added before shipping changes to those paths.
|
||||
|
||||
## Next PR acceptance: shared-renderer extraction
|
||||
|
||||
1. Move code into a few coherent modules; retain JSX structure and behavior.
|
||||
2. Preserve context/provider ownership and reactive reads; avoid new subscriptions.
|
||||
3. Compare extracted component bodies to the baseline and run affected render tests.
|
||||
4. Repeat typecheck and TUI suite; replay Drive fixture and compare layouts.
|
||||
5. Record diff scope, results, evidence and PR URL here. Keep diagnostic-only files out of the PR.
|
||||
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
|
||||
null,
|
||||
2,
|
||||
)
|
||||
: formatTable(page.data)) + EOL
|
||||
: formatList(page.data)) + EOL
|
||||
const write = Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
|
||||
),
|
||||
)
|
||||
|
||||
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
|
||||
const rows = sessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
updated: new Date(session.time.updated).toLocaleString(),
|
||||
}))
|
||||
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
|
||||
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
|
||||
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
|
||||
return [
|
||||
header,
|
||||
"─".repeat(header.length),
|
||||
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
|
||||
].join(EOL)
|
||||
function formatList(sessions: ReadonlyArray<SessionInfo>) {
|
||||
return sessions
|
||||
.map((session) =>
|
||||
[
|
||||
session.id,
|
||||
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
new Date(session.time.updated).toLocaleString(),
|
||||
].join("\t"),
|
||||
)
|
||||
.join(EOL)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RelativePath } from "@opencode/schema/schema"
|
||||
import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { DateTime } from "effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
@@ -26,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
|
||||
import type { Form } from "@opencode/schema/form"
|
||||
import type { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Credential } from "@opencode/schema/credential"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { PermissionSaved } from "@opencode/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode/schema/filesystem"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionCreateOutput = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
|
||||
@@ -437,6 +438,7 @@ export type SessionLogOutput =
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
@@ -489,6 +491,15 @@ export type SessionLogOutput =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.permissions.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -1585,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
|
||||
input: PermissionReplyInput,
|
||||
) => Effect.Effect<PermissionReplyOutput, E>
|
||||
|
||||
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
export type PermissionRulesOutput = void
|
||||
export type PermissionRulesOperation<E = never> = (
|
||||
input: PermissionRulesInput,
|
||||
) => Effect.Effect<PermissionRulesOutput, E>
|
||||
|
||||
export interface PermissionApi<E = never> {
|
||||
readonly request: { readonly list: PermissionRequestListOperation<E> }
|
||||
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
|
||||
@@ -1592,6 +1609,7 @@ export interface PermissionApi<E = never> {
|
||||
readonly list: PermissionListOperation<E>
|
||||
readonly get: PermissionGetOperation<E>
|
||||
readonly reply: PermissionReplyOperation<E>
|
||||
readonly rules: PermissionRulesOperation<E>
|
||||
}
|
||||
|
||||
export type FileListInput = {
|
||||
|
||||
@@ -181,6 +181,8 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileListInput,
|
||||
FileListOutput,
|
||||
FileFindInput,
|
||||
@@ -395,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -1145,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
|
||||
preserveEffect<PermissionRulesOutput>()(
|
||||
raw["session.permission.rules"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
request: { list: EndpointPermissionRequestList(raw) },
|
||||
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
|
||||
@@ -1152,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
list: EndpointPermissionList(raw),
|
||||
get: EndpointPermissionGet(raw),
|
||||
reply: EndpointPermissionReply(raw),
|
||||
rules: EndpointPermissionRules(raw),
|
||||
})
|
||||
|
||||
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
|
||||
|
||||
@@ -175,6 +175,8 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileReadInput,
|
||||
FileReadOutput,
|
||||
FileListInput,
|
||||
@@ -565,6 +567,7 @@ export function make(options: ClientOptions) {
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1566,6 +1569,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionRulesOutput>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
|
||||
body: { permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
file: {
|
||||
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -551,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
|
||||
|
||||
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1651,24 +1629,6 @@ export type SessionInboxMove = {
|
||||
delivery: SessionInboxDelivery
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionRevertStaged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1912,6 +1872,58 @@ export type AgentInfo = {
|
||||
permissions: PermissionRuleset
|
||||
}
|
||||
|
||||
export type SessionPermissionsUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.permissions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; permissions: PermissionRuleset }
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
@@ -2084,8 +2096,6 @@ export type ConfigEntry =
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxUser = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -2140,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
|
||||
|
||||
export type SessionInboxEnqueued = {
|
||||
@@ -2233,6 +2245,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2292,6 +2305,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
@@ -2804,6 +2818,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
@@ -2812,6 +2831,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
@@ -2820,6 +2844,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
@@ -2828,6 +2857,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
@@ -2836,6 +2870,11 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["location"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
@@ -2844,7 +2883,25 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type SessionCreateOutput = { data: SessionInfo }["data"]
|
||||
@@ -2882,6 +2939,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3187,6 +3249,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3492,6 +3559,11 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -5753,6 +5825,19 @@ export type PermissionReplyInput = {
|
||||
|
||||
export type PermissionReplyOutput = void
|
||||
|
||||
export type PermissionRulesInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly permissions: {
|
||||
readonly permissions: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type PermissionRulesOutput = void
|
||||
|
||||
export type FileReadInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.permissions.updated":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
|
||||
return
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -93,4 +94,5 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260910120000_clear_v1_session_permission",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
|
||||
@@ -154,7 +154,7 @@ const layer = Layer.effect(
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
|
||||
})
|
||||
|
||||
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
|
||||
|
||||
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
|
||||
),
|
||||
),
|
||||
rules: sessions.setPermissions,
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
metadata: input?.metadata,
|
||||
permissions: input?.permissions,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import path from "path"
|
||||
@@ -81,6 +82,7 @@ type CreateBaseInput = {
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
metadata?: SessionSchema.Metadata
|
||||
permissions?: Permission.Ruleset
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
@@ -157,6 +159,10 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly setPermissions: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
permissions: Permission.Ruleset
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: SessionMove.Interface["move"]
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
@@ -248,9 +254,10 @@ const layer = Layer.effect(
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
// Children inherit metadata the way they inherit location, so
|
||||
// host policies that read it treat the family uniformly.
|
||||
// Children inherit metadata and permissions the way they inherit
|
||||
// location, so host policies that read them treat the family uniformly.
|
||||
metadata: input.metadata ?? parent?.metadata,
|
||||
permissions: input.permissions ?? parent?.permissions,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
@@ -387,6 +394,7 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
wait: (sessionID) => sessions.forSession(sessionID).wait(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionContext from "./context.js"
|
||||
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
@@ -129,7 +130,7 @@ const layer = Layer.effect(
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(agent.info.permissions),
|
||||
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
|
||||
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
metadata: row.metadata ?? undefined,
|
||||
permissions: row.permission ?? undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
outcome: row.idle_outcome ?? undefined,
|
||||
time: {
|
||||
|
||||
@@ -116,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
}),
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.permissions.updated": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
"session.inbox.delivered": () => Effect.void,
|
||||
|
||||
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
permission: parent.permission,
|
||||
version: parent.version,
|
||||
cost: 0,
|
||||
tokens_input: 0,
|
||||
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.data.metadata,
|
||||
permission: event.data.permissions,
|
||||
version: event.data.version,
|
||||
time_created: event.created,
|
||||
time_updated: event.created,
|
||||
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ permission: event.data.permissions, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Viewed, (event) => {
|
||||
const idle = event.data.idle
|
||||
return db
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
|
||||
import { DateTime, Effect, Fiber, Scope } from "effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const setPermissions = Effect.fn("Session.setPermissions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { permissions: Permission.Ruleset },
|
||||
) {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
|
||||
})
|
||||
const switchAgent = Effect.fn("Session.switchAgent")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { agent: Agent.ID },
|
||||
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
const switchModel = operations.switchModel.bind(undefined, sessionID)
|
||||
const inbox = operations.inbox.bind(undefined, sessionID)
|
||||
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { PermissionV1 } from "@opencode/schema/permission-v1"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Project } from "@opencode/schema/project"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import type { Workspace } from "@opencode/schema/workspace"
|
||||
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
|
||||
@@ -103,6 +103,7 @@ const layer = Layer.effect(
|
||||
agent: input.data.info.agent,
|
||||
model: input.data.info.model,
|
||||
metadata: input.data.info.metadata,
|
||||
permissions: input.data.info.permissions,
|
||||
},
|
||||
{
|
||||
location: input.location,
|
||||
|
||||
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const output = () => {
|
||||
if (result.structured !== undefined) return result.structured
|
||||
if (text === "") return null
|
||||
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
|
||||
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
output: output(),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
|
||||
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
description: "Status",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "issues",
|
||||
description: "Returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "count",
|
||||
description: "Returns a number as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "typed",
|
||||
description: "Declares a string output and returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { type: "string" },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "issues",
|
||||
codemode: false,
|
||||
description: "Returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
if (input.name === "issues" || input.name === "typed")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
|
||||
})
|
||||
if (input.name === "count")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
"direct_fail",
|
||||
"direct_issues",
|
||||
"direct_lookup",
|
||||
"direct_media",
|
||||
"execute",
|
||||
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses JSON text results from MCP tools without an output schema", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
const run = (code: string) =>
|
||||
toolSet
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_json_text"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
|
||||
})
|
||||
.pipe(Effect.map((execution) => execution.output.output))
|
||||
|
||||
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
|
||||
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
|
||||
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
|
||||
|
||||
// Outside Code Mode the content the model reads is the original text.
|
||||
expect(
|
||||
yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_json_text"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
|
||||
}),
|
||||
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -224,6 +224,34 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges session rules after agent rules and before saved approvals", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const { db } = yield* Database.Service
|
||||
const service = yield* Permission.Service
|
||||
const setSession = (permission: Permission.Ruleset) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ permission })
|
||||
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
|
||||
effect: "deny",
|
||||
})
|
||||
|
||||
yield* setRules([])
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
|
||||
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
|
||||
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
||||
@@ -108,6 +108,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
reply: () => Effect.die("unused permission.reply"),
|
||||
rules: () => Effect.die("unused permission.rules"),
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
|
||||
@@ -388,6 +388,32 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
|
||||
|
||||
const created = yield* session.create({ location, permissions })
|
||||
expect(created.permissions).toEqual(permissions)
|
||||
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
|
||||
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
|
||||
|
||||
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, created.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect(forked.permissions).toEqual(permissions)
|
||||
|
||||
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
|
||||
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
|
||||
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
|
||||
expect(
|
||||
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.NotFoundError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits location from an existing parent when omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -1330,7 +1356,12 @@ describe("SessionTransfer", () => {
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
|
||||
const template = yield* session.create({
|
||||
location,
|
||||
title: "Exported",
|
||||
metadata: { channel: "C123" },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
const sessionID = Session.ID.create()
|
||||
const sourceMessageID = SessionMessage.ID.create()
|
||||
const errorMessageID = SessionMessage.ID.create()
|
||||
@@ -1376,7 +1407,13 @@ describe("SessionTransfer", () => {
|
||||
})
|
||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
||||
|
||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
|
||||
expect(imported).toMatchObject({
|
||||
id: sessionID,
|
||||
title: "Exported",
|
||||
location,
|
||||
metadata: { channel: "C123" },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(imported.time).toMatchObject({
|
||||
updated: DateTime.makeUnsafe(1_000),
|
||||
idle: DateTime.makeUnsafe(200),
|
||||
|
||||
@@ -19,6 +19,6 @@ export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -438,6 +438,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
|
||||
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
|
||||
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
|
||||
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
|
||||
},
|
||||
plugin: {
|
||||
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
|
||||
@@ -19,6 +19,6 @@ export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -132,4 +132,21 @@ export const makePermissionGroup = <
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ permissions: Permission.Ruleset }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.rules",
|
||||
summary: "Replace session permission rules",
|
||||
description:
|
||||
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -175,6 +176,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
location: Location.Ref.pipe(Schema.optional),
|
||||
metadata: Session.Metadata.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TokenUsage } from "./token-usage.js"
|
||||
import { SessionInbox } from "./session-inbox.js"
|
||||
import { Project } from "./project.js"
|
||||
import { SessionFork } from "./session-fork.js"
|
||||
import { Permission } from "./permission.js"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
@@ -62,6 +63,7 @@ export const Created = Event.durable({
|
||||
model: Model.Ref.pipe(optional),
|
||||
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
|
||||
metadata: SessionMetadata.pipe(optional),
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
version: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -109,6 +111,16 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const PermissionsUpdated = Event.durable({
|
||||
type: "session.permissions.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
permissions: Permission.Ruleset,
|
||||
},
|
||||
})
|
||||
export type PermissionsUpdated = typeof PermissionsUpdated.Type
|
||||
|
||||
export const Viewed = Event.durable({
|
||||
type: "session.viewed",
|
||||
...options,
|
||||
@@ -634,6 +646,7 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
PermissionsUpdated,
|
||||
Viewed,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionEvent } from "./session-event.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { SessionMetadata } from "./session-metadata.js"
|
||||
import { Money } from "./money.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { TokenUsage } from "./token-usage.js"
|
||||
import { Revert } from "./session-revert.js"
|
||||
import { SessionFork } from "./session-fork.js"
|
||||
@@ -54,6 +55,8 @@ export const Info = Schema.Struct({
|
||||
location: Location.Ref,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
metadata: Metadata.pipe(optional),
|
||||
/** Evaluated after the agent's rules; the last matching rule wins. */
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
revert: Revert.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Info" })
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.permissions.updated.1",
|
||||
"session.viewed.1",
|
||||
"session.message.content.updated.1",
|
||||
"session.usage.recorded.1",
|
||||
|
||||
@@ -83,6 +83,15 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.permission.rules",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* sessions
|
||||
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"permission.saved.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -120,6 +120,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
agent: ctx.payload.agent,
|
||||
model: ctx.payload.model,
|
||||
metadata: ctx.payload.metadata,
|
||||
permissions: ctx.payload.permissions,
|
||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("inference stat normalization", () => {
|
||||
})
|
||||
|
||||
test("merges renamed models under their current name", () => {
|
||||
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
|
||||
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
|
||||
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
|
||||
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
|
||||
|
||||
@@ -14,6 +14,7 @@ export const MODEL_AUTHOR_RULES = [
|
||||
] as const
|
||||
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
|
||||
export const MODEL_NAME_ALIASES: Record<string, string> = {
|
||||
"deepseek-flash": "deepseek-v4.1-flash",
|
||||
"x-preview-f": "ox-alpha",
|
||||
"xiaomi/mimo-v2.5": "mimo-v2.5",
|
||||
}
|
||||
|
||||
@@ -274,6 +274,7 @@ export const Definitions = {
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("return", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.error": keybind("space", "View plugin error"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
|
||||
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
|
||||
|
||||
@@ -223,6 +223,15 @@ export function PluginsDialog(props: {
|
||||
disabled: checking(),
|
||||
onTrigger: check,
|
||||
},
|
||||
{
|
||||
title: "view error",
|
||||
command: "dialog.plugins.error",
|
||||
hidden: !pluginError(focusedTui()),
|
||||
onTrigger: (option) => {
|
||||
const entry = entries().find((entry) => entry.key === option.value)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
@@ -239,7 +248,7 @@ export function PluginsDialog(props: {
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
|
||||
<text>
|
||||
<span style={{ fg: props.context.theme.text.default }}>
|
||||
<b>enter</b>
|
||||
|
||||
@@ -13,6 +13,10 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
// the context back, so the context must load first exactly as it does in the app.
|
||||
import type { usePlugin } from "../../../src/plugin/context"
|
||||
import "../../../src/plugin/context"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider } from "../../../src/context/data"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
@@ -32,11 +36,19 @@ function packagePlugin(outdated: boolean): PluginInfo {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
|
||||
async function renderPlugins(
|
||||
root: string,
|
||||
inventory: { list: PluginInfo[]; check: PluginInfo[] },
|
||||
tui?: {
|
||||
registered: { id: string; source: "builtin" | "external"; active: boolean }[]
|
||||
list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[]
|
||||
},
|
||||
) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const requests: { path: string; body: unknown }[] = []
|
||||
const toasts: ToastOptions[] = []
|
||||
const activations: string[] = []
|
||||
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
|
||||
const transport = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
|
||||
@@ -49,13 +61,14 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
})
|
||||
const api = createApi(transport.fetch)
|
||||
|
||||
function Harness() {
|
||||
function Content() {
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
const theme = useThemes().currentTokens()
|
||||
const context = {
|
||||
client: createApi(transport.fetch),
|
||||
client: api,
|
||||
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
|
||||
get theme() {
|
||||
return theme
|
||||
@@ -66,9 +79,12 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
|
||||
},
|
||||
} as unknown as Context
|
||||
const plugins = {
|
||||
registered: () => [],
|
||||
list: () => [],
|
||||
activate: async () => true,
|
||||
registered: () => tui?.registered ?? [],
|
||||
list: () => tui?.list ?? [],
|
||||
activate: async (id: string) => {
|
||||
activations.push(id)
|
||||
return true
|
||||
},
|
||||
deactivate: async () => true,
|
||||
} as unknown as ReturnType<typeof usePlugin>
|
||||
return <PluginsDialog context={context} plugins={plugins} />
|
||||
@@ -77,15 +93,23 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
|
||||
return (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={root}>
|
||||
<LocationProvider>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
@@ -93,10 +117,46 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
|
||||
return { app, requests, toasts }
|
||||
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
|
||||
await app.waitForFrame((frame) => frame.includes(expected))
|
||||
return { app, requests, toasts, activations }
|
||||
}
|
||||
|
||||
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await renderPlugins(
|
||||
tmp.path,
|
||||
{ list: [], check: [] },
|
||||
{
|
||||
registered: [{ id: "broken.plugin", source: "external", active: false }],
|
||||
list: [
|
||||
{
|
||||
target: "./broken.ts",
|
||||
id: "broken.plugin",
|
||||
status: "failed",
|
||||
error: "Plugin setup failed",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("enable")
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.activations.length === 1)
|
||||
expect(fixture.activations).toEqual(["broken.plugin"])
|
||||
|
||||
fixture.app.mockInput.pressKey(" ")
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
|
||||
)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("checking for updates refreshes the inventory and reveals the update action", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
|
||||
|
||||
@@ -656,6 +656,16 @@ const request = await ctx.permission.get({ sessionID, requestID })
|
||||
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
|
||||
```
|
||||
|
||||
Replace the session-scoped permission rules. They are evaluated after the agent's rules, and the
|
||||
last matching rule wins. Child sessions inherit the rules in effect when they are created.
|
||||
|
||||
```ts
|
||||
await ctx.permission.rules({
|
||||
sessionID,
|
||||
permissions: [{ action: "edit", resource: "/path/to/original/checkout/**", effect: "deny" }],
|
||||
})
|
||||
```
|
||||
|
||||
### Sessions
|
||||
|
||||
Create or read a session.
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
---
|
||||
title: "commands"
|
||||
description: "Reference for the opencode2 command line."
|
||||
---
|
||||
|
||||
Every command accepts `--help` for its full flag list, for example `opencode2 run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
|
||||
|
||||
## run
|
||||
|
||||
`opencode2 run` sends a message and prints the reply without opening the interactive interface.
|
||||
|
||||
```bash
|
||||
$ opencode2 run "Explain this repository"
|
||||
```
|
||||
|
||||
Choose a model.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
|
||||
```
|
||||
|
||||
Continue the last session.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --continue "Now handle the expired case"
|
||||
```
|
||||
|
||||
Emit newline-delimited JSON for scripts.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --format json "List the TODO comments"
|
||||
```
|
||||
|
||||
Attach files to the message.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --file src/server.ts --file src/client.ts "Review these for bugs"
|
||||
```
|
||||
|
||||
Run with a specific agent.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --agent build "Fix the failing test"
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 run --help
|
||||
```
|
||||
|
||||
## mini
|
||||
|
||||
`opencode2 mini` starts the minimal interactive interface instead of the full-screen TUI.
|
||||
|
||||
```bash
|
||||
$ opencode2 mini
|
||||
```
|
||||
|
||||
Continue the last session.
|
||||
|
||||
```bash
|
||||
$ opencode2 mini --continue
|
||||
```
|
||||
|
||||
Start with a model and an initial prompt.
|
||||
|
||||
```bash
|
||||
$ opencode2 mini --model anthropic/claude-sonnet-4-5 --prompt "Summarize this repository"
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 mini --help
|
||||
```
|
||||
|
||||
## session
|
||||
|
||||
`opencode2 session` manages sessions.
|
||||
|
||||
```bash
|
||||
$ opencode2 session list
|
||||
```
|
||||
|
||||
Limit the list and print JSON.
|
||||
|
||||
```bash
|
||||
$ opencode2 session list --max-count 20 --format json
|
||||
```
|
||||
|
||||
Delete a session and its child sessions.
|
||||
|
||||
```bash
|
||||
$ opencode2 session delete ses_9c1b08
|
||||
```
|
||||
|
||||
Export session data as JSON.
|
||||
|
||||
```bash
|
||||
$ opencode2 session export ses_4f2a1c
|
||||
```
|
||||
|
||||
Redact sensitive transcript and file data when exporting.
|
||||
|
||||
```bash
|
||||
$ opencode2 session export ses_4f2a1c --sanitize
|
||||
```
|
||||
|
||||
Import session data from a JSON file or URL.
|
||||
|
||||
```bash
|
||||
$ opencode2 session import session.json
|
||||
```
|
||||
|
||||
Import into a specific directory.
|
||||
|
||||
```bash
|
||||
$ opencode2 session import session.json --directory ~/code/project
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 session --help
|
||||
```
|
||||
|
||||
## auth
|
||||
|
||||
`opencode2 auth` manages AI providers and credentials.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth list
|
||||
```
|
||||
|
||||
List them as JSON.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth list --format json
|
||||
```
|
||||
|
||||
Log in to a provider.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth login anthropic
|
||||
```
|
||||
|
||||
Log in with a specific authentication method.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth login anthropic --method api-key
|
||||
```
|
||||
|
||||
Log out of a saved account.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth logout anthropic work
|
||||
```
|
||||
|
||||
Switch the active account for an integration.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth switch anthropic work
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 auth --help
|
||||
```
|
||||
|
||||
## models
|
||||
|
||||
`opencode2 models` lists every available model.
|
||||
|
||||
```bash
|
||||
$ opencode2 models
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 models --help
|
||||
```
|
||||
|
||||
## mcp
|
||||
|
||||
`opencode2 mcp` manages MCP (Model Context Protocol) servers.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp list
|
||||
```
|
||||
|
||||
Add a remote server.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp
|
||||
```
|
||||
|
||||
Add a local server to the global config.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp add everything --global -- npx -y @modelcontextprotocol/server-everything
|
||||
```
|
||||
|
||||
Add a local server with an environment variable.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp add everything --env LOG_LEVEL=debug -- npx -y @modelcontextprotocol/server-everything
|
||||
```
|
||||
|
||||
Add a remote server with a header.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp --header CONTEXT7_API_KEY=secret
|
||||
```
|
||||
|
||||
Authenticate with an OAuth-capable remote server.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp auth sentry
|
||||
```
|
||||
|
||||
Remove stored OAuth credentials for a server.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp logout sentry
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 mcp --help
|
||||
```
|
||||
|
||||
## plugin
|
||||
|
||||
`opencode2 plugin` manages plugins.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin list
|
||||
```
|
||||
|
||||
Include built-in server plugins.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin list --builtin
|
||||
```
|
||||
|
||||
Install a plugin and add it to the global configuration.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin add @example/opencode-tui
|
||||
```
|
||||
|
||||
Check package plugins for updates.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin check
|
||||
```
|
||||
|
||||
Update package plugins.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin update
|
||||
```
|
||||
|
||||
Remove a plugin from global configuration.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin remove @example/opencode-tui
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 plugin --help
|
||||
```
|
||||
|
||||
## stats
|
||||
|
||||
`opencode2 stats` shows shareable usage statistics.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats
|
||||
```
|
||||
|
||||
Show the last 7 days.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats --days 7
|
||||
```
|
||||
|
||||
Show model usage.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats --models
|
||||
```
|
||||
|
||||
Show cost and token details.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats --cost
|
||||
```
|
||||
|
||||
Print JSON instead of a report.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats --json
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 stats --help
|
||||
```
|
||||
|
||||
## serve
|
||||
|
||||
`opencode2 serve` starts the API and web server. See [Web](/cli/web).
|
||||
|
||||
```bash
|
||||
$ opencode2 serve
|
||||
```
|
||||
|
||||
Bind to all interfaces on a fixed port.
|
||||
|
||||
```bash
|
||||
$ opencode2 serve --hostname 0.0.0.0 --port 4096
|
||||
```
|
||||
|
||||
Allow a browser client from another origin.
|
||||
|
||||
```bash
|
||||
$ opencode2 serve --cors https://app.example.com
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 serve --help
|
||||
```
|
||||
|
||||
## pair
|
||||
|
||||
`opencode2 pair` shows server pairing information, including URLs, credentials, and a QR code.
|
||||
|
||||
```bash
|
||||
$ opencode2 pair
|
||||
```
|
||||
|
||||
Advertise an external URL in the QR code.
|
||||
|
||||
```bash
|
||||
$ opencode2 pair --url https://dev.example.com
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 pair --help
|
||||
```
|
||||
|
||||
## service
|
||||
|
||||
`opencode2 service` manages the background server. See [Web](/cli/web).
|
||||
|
||||
```bash
|
||||
$ opencode2 service start
|
||||
```
|
||||
|
||||
Restart it.
|
||||
|
||||
```bash
|
||||
$ opencode2 service restart
|
||||
```
|
||||
|
||||
Show its status.
|
||||
|
||||
```bash
|
||||
$ opencode2 service status
|
||||
```
|
||||
|
||||
Stop it.
|
||||
|
||||
```bash
|
||||
$ opencode2 service stop
|
||||
```
|
||||
|
||||
Read a setting.
|
||||
|
||||
```bash
|
||||
$ opencode2 service get hostname
|
||||
```
|
||||
|
||||
Set a setting.
|
||||
|
||||
```bash
|
||||
$ opencode2 service set hostname 0.0.0.0
|
||||
```
|
||||
|
||||
Allow an extra CORS origin.
|
||||
|
||||
```bash
|
||||
$ opencode2 service set cors https://app.example.com
|
||||
```
|
||||
|
||||
Pass an environment variable to the server process.
|
||||
|
||||
```bash
|
||||
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
|
||||
```
|
||||
|
||||
Reset a setting to its default.
|
||||
|
||||
```bash
|
||||
$ opencode2 service unset hostname
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 service --help
|
||||
```
|
||||
|
||||
## api
|
||||
|
||||
`opencode2 api` makes a request to the running server.
|
||||
|
||||
```bash
|
||||
$ opencode2 api GET /api/session
|
||||
```
|
||||
|
||||
Call an operation ID with a query parameter.
|
||||
|
||||
```bash
|
||||
$ opencode2 api v2.session.list --param limit=10
|
||||
```
|
||||
|
||||
Send a JSON body.
|
||||
|
||||
```bash
|
||||
$ opencode2 api v2.session.create --data '{"title": "New session"}'
|
||||
```
|
||||
|
||||
Add a request header.
|
||||
|
||||
```bash
|
||||
$ opencode2 api GET /api/session -H "accept: application/json"
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 api --help
|
||||
```
|
||||
|
||||
## acp
|
||||
|
||||
`opencode2 acp` starts an Agent Client Protocol server over stdin and stdout for editor integrations. It runs until the client closes the connection.
|
||||
|
||||
```bash
|
||||
$ opencode2 acp
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 acp --help
|
||||
```
|
||||
|
||||
## debug
|
||||
|
||||
`opencode2 debug` provides debugging and troubleshooting tools.
|
||||
|
||||
```bash
|
||||
$ opencode2 debug agents
|
||||
```
|
||||
|
||||
List configuration sources.
|
||||
|
||||
```bash
|
||||
$ opencode2 debug config
|
||||
```
|
||||
|
||||
Show global paths.
|
||||
|
||||
```bash
|
||||
$ opencode2 debug paths
|
||||
```
|
||||
|
||||
Print a single path.
|
||||
|
||||
```bash
|
||||
$ opencode2 debug paths db
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 debug --help
|
||||
```
|
||||
|
||||
## upgrade
|
||||
|
||||
`opencode2 upgrade` upgrades OpenCode to the latest or a specific version. Alias: `update`.
|
||||
|
||||
```bash
|
||||
$ opencode2 upgrade
|
||||
```
|
||||
|
||||
Upgrade to a specific version with a specific package manager.
|
||||
|
||||
```bash
|
||||
$ opencode2 upgrade 1.18.15 --method bun
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 upgrade --help
|
||||
```
|
||||
|
||||
## uninstall
|
||||
|
||||
`opencode2 uninstall` removes OpenCode and all related files.
|
||||
|
||||
```bash
|
||||
$ opencode2 uninstall
|
||||
```
|
||||
|
||||
Preview what would be removed.
|
||||
|
||||
```bash
|
||||
$ opencode2 uninstall --dry-run
|
||||
```
|
||||
|
||||
Keep configuration and session data.
|
||||
|
||||
```bash
|
||||
$ opencode2 uninstall --keep-config --keep-data
|
||||
```
|
||||
|
||||
View all subcommands and flags.
|
||||
|
||||
```bash
|
||||
$ opencode2 uninstall --help
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Web"
|
||||
description: "Run OpenCode in the browser."
|
||||
---
|
||||
|
||||
OpenCode ships with a web ui that is served from the same server that powers the
|
||||
TUI. It's available by default and password protected.
|
||||
|
||||
## Access
|
||||
|
||||
```bash
|
||||
$ opencode2 pair
|
||||
|
||||
URLs http://127.0.0.1:49374
|
||||
Username opencode
|
||||
Password ********
|
||||
```
|
||||
|
||||
By default the server runs on port 49374 and listens only on localhost. You can
|
||||
change this config with the `opencode2 service` command.
|
||||
|
||||
## Configure
|
||||
|
||||
Set any option with `opencode2 service set`:
|
||||
|
||||
```bash
|
||||
# Listen on every network interface
|
||||
$ opencode2 service set hostname 0.0.0.0
|
||||
|
||||
# Use a fixed port instead of the channel default
|
||||
$ opencode2 service set port 49374
|
||||
|
||||
# Replace the generated password
|
||||
$ opencode2 service set password "a-long-secret"
|
||||
|
||||
# Allow a web client served from another origin
|
||||
$ opencode2 service set cors https://app.example.com,https://other.example.com
|
||||
|
||||
# Pass an environment variable to the server process
|
||||
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
|
||||
```
|
||||
|
||||
Changing a setting stops the background server. To apply the new config
|
||||
|
||||
```bash
|
||||
$ opencode2 service start
|
||||
```
|
||||
|
||||
## Standalone
|
||||
|
||||
`opencode2 serve` runs the same server in the foreground instead of through the
|
||||
shared background service.
|
||||
|
||||
```bash
|
||||
$ opencode2 serve --hostname 0.0.0.0 --port 4096
|
||||
server listening on http://0.0.0.0:4096
|
||||
server password <password>
|
||||
```
|
||||
|
||||
Use it when you want to:
|
||||
|
||||
- Run OpenCode on a shared, always-on, or remote host, then connect clients with
|
||||
`opencode2 --server <url>`.
|
||||
- Control the hostname, port, and CORS origins for a single process.
|
||||
- Run under a supervisor like systemd, Docker, or another environment that expects
|
||||
a foreground process.
|
||||
- Keep a dedicated server instead of the shared background service.
|
||||
|
||||
|
||||
Connect a client to it with `--server`:
|
||||
|
||||
```bash
|
||||
$ opencode2 --server http://127.0.0.1:4096
|
||||
```
|
||||
@@ -247,24 +247,6 @@ field, but it does not run formatters yet.
|
||||
|
||||
See the [formatters guide](/formatters) for accepted fields and current limitations.
|
||||
|
||||
### LSP
|
||||
|
||||
Define language server settings for compatibility and future use. V2 accepts
|
||||
this field, but it does not start language servers yet.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"lsp": {
|
||||
"typescript": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See the [LSP guide](/lsp) for accepted fields and current limitations.
|
||||
|
||||
### Media
|
||||
|
||||
Control how oversized images loaded by the `read` tool are resized or rejected
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
title: "LSP"
|
||||
---
|
||||
|
||||
Language Server Protocol (LSP) integrations can provide code diagnostics,
|
||||
symbols, definitions, references, and other language-aware context.
|
||||
|
||||
<Callout type="warning">
|
||||
OpenCode V2 does not yet have an LSP runtime or built-in language servers. The `lsp` configuration is accepted and
|
||||
preserved, but it does not currently start or download servers, expose an LSP tool, or add diagnostics to file tool
|
||||
results.
|
||||
</Callout>
|
||||
|
||||
## Built-in servers
|
||||
|
||||
There are no built-in LSP servers in the current V2 implementation. Setting
|
||||
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
|
||||
effect until V2 provides a server registry and LSP runtime.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": true,
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `lsp` field accepts a boolean or an object keyed by server name:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": {
|
||||
"custom-typescript": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"env": {
|
||||
"TSS_LOG": "-level verbose",
|
||||
},
|
||||
"initialization": {
|
||||
"preferences": {
|
||||
"importModuleSpecifierPreference": "relative",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Each enabled server entry has this shape:
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ---------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------- |
|
||||
| `command` | `string[]` | Yes | Executable followed by any arguments. |
|
||||
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
|
||||
| `disabled` | `boolean` | No | Disables the entry when `true`. |
|
||||
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
|
||||
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
|
||||
|
||||
The only entry that may omit `command` is the disable-only form:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"lsp": {
|
||||
"typescript": {
|
||||
"disabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
|
||||
including for a custom server, although a future runtime will need a way to
|
||||
associate that server with files.
|
||||
|
||||
## Disable LSP
|
||||
|
||||
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
|
||||
disable the whole integration, including when a lower-priority configuration
|
||||
set it to `true` or supplied an object:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": false,
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "disabled": true }` under a server name to disable one server while
|
||||
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
|
||||
V2 currently performs no automatic LSP downloads.
|
||||
|
||||
## Current usage
|
||||
|
||||
V2 loads and validates the configuration shape for compatibility and future
|
||||
integration. It does not currently use LSP when reading, writing, editing, or
|
||||
patching files, and those tools do not notify a language server or return LSP
|
||||
diagnostics.
|
||||
|
||||
For reliable feedback today, have the agent run the project's lint, typecheck,
|
||||
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
|
||||
skill so the agent knows when and where to run them.
|
||||
@@ -1,35 +1,5 @@
|
||||
---
|
||||
title: "Session sharing"
|
||||
title: "Sharing"
|
||||
---
|
||||
|
||||
Session sharing is not yet available in OpenCode V2. V2 does not currently
|
||||
publish sessions, upload conversation history to a sharing service, or create
|
||||
public links.
|
||||
|
||||
There is no functional share or unshare server API endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
The V2 configuration schema accepts a `share` field with three values:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual",
|
||||
}
|
||||
```
|
||||
|
||||
- `"manual"` represents sharing only when explicitly requested.
|
||||
- `"auto"` represents automatically sharing new sessions.
|
||||
- `"disabled"` represents preventing session sharing.
|
||||
|
||||
These values are parsed but are not acted on by the current V2 runtime. In
|
||||
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
|
||||
V2 leaves the sharing policy unspecified.
|
||||
|
||||
## Beta limitations
|
||||
|
||||
V2 currently provides no public session viewer, share URL, history sync,
|
||||
retention controls, or unshare/delete operation. Until those surfaces are
|
||||
implemented in the V2 server and protocol, keep using sessions locally and do
|
||||
not treat the `share` configuration field as a privacy or publishing control.
|
||||
OpenCode V2 does not support session sharing yet.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Session warming"
|
||||
title: "Warming"
|
||||
---
|
||||
|
||||
Session warming sends periodic model requests for recently active sessions.
|
||||
|
||||
@@ -30,7 +30,6 @@ export const docsSections: DocsSection[] = [
|
||||
{
|
||||
title: "Configure",
|
||||
items: [
|
||||
{ title: "LSP", slug: "lsp" },
|
||||
{ title: "Agents", slug: "agents" },
|
||||
{ title: "Models", slug: "models" },
|
||||
{ title: "Skills", slug: "skills" },
|
||||
@@ -46,8 +45,8 @@ export const docsSections: DocsSection[] = [
|
||||
{ title: "MCP servers", slug: "mcp-servers" },
|
||||
{ title: "Permissions", slug: "permissions" },
|
||||
{ title: "Instructions", slug: "instructions" },
|
||||
{ title: "Session sharing", slug: "sharing" },
|
||||
{ title: "Session warming", slug: "warming" },
|
||||
{ title: "Sharing", slug: "sharing" },
|
||||
{ title: "Warming", slug: "warming" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -67,11 +66,8 @@ export const docsSections: DocsSection[] = [
|
||||
items: [
|
||||
{ title: "Intro", slug: "cli" },
|
||||
{ title: "Config", slug: "cli/config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Configure",
|
||||
items: [
|
||||
{ title: "Web", slug: "cli/web" },
|
||||
{ title: "Commands", slug: "cli/commands" },
|
||||
{ title: "Theme", slug: "cli/theme" },
|
||||
{ title: "Plugins", slug: "cli/plugins" },
|
||||
{ title: "Keybinds", slug: "cli/keybinds" },
|
||||
|
||||
Reference in New Issue
Block a user