chore: import upstream snapshot with attribution
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:49 +08:00
commit 98e40dac97
2013 changed files with 268743 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# OpenCode
Reviewed on 2026-03-21 from `../happy-adjacent/research/opencode` at commit
`2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`.
## Why it matters
OpenCode is currently the strongest overall product reference for Happy.
- the desktop UI is excellent
- the feature set feels coherent and ambitious
- clicking the context field opens a genuinely useful debug/context inspector
- the transcript and sync model look much closer to what Happy should want than a flat custom message stream
## Current take
- Use OpenCode as the leading reference for transcript design.
- Strongly consider adopting its message-and-parts direction instead of inventing another bespoke message protocol.
- Keep digging into how it syncs state between client and server; there is a lot to learn there.
- Read `runtime-tracing.md` first if you want the real story. That file now has
concrete permission, media, subtask, and routing flows with actual payload
snippets from a source-run OpenCode server.
## Key findings
- transcript model: stable message envelopes with ordered typed parts
- live updates: incremental SSE-style event stream plus paged fetch hydration
- subagents: child sessions created through the `task` tool, resumable by `task_id`
- task tracking: first-class todo store, not buried in message text
- permissions: first-class request objects plus pattern rules and decision history
- sandbox: workspace/worktree isolation, not a strict OS sandbox
## Important repo files
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
See `docs/competition/opencode/message-protocol.md`,
`docs/competition/opencode/runtime-tracing.md`, and
`docs/competition/opencode/sources.md`.
@@ -0,0 +1,149 @@
# OpenCode Message Protocol
## Bottom line
OpenCode has the cleanest transcript shape of the three systems reviewed so far.
If Happy wants a strong protocol reference for app + server + session UI, this is
the one to steal from first.
## Core transcript model
The key design is: message envelope first, typed parts second.
- messages are stable top-level records with IDs, session linkage, model/provider metadata, path, token usage, cost, and error state
- content is not a single blob; it is an ordered list of typed parts
- important part kinds include `text`, `reasoning`, `tool`, `file`, `snapshot`, `patch`, `agent`, `subtask`, `step-start`, `step-finish`, and `compaction`
- this makes streaming, partial updates, replay, and debug rendering much cleaner than mutating one giant assistant string
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
## Live event model
OpenCode separates full transcript state from incremental transport events.
- canonical live events include `message.updated`, `message.part.updated`, `message.part.delta`, `message.part.removed`, `message.removed`, `session.status`, `todo.updated`, and `permission.asked`
- `message.part.delta` is the streaming primitive for appending text into a named field
- later `message.part.updated` events can replace or supersede earlier deltas
- the UI reducer explicitly merges stream events into cached transcript state
This is a very good pattern for Happy: use events for freshness, not as the only
source of truth.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync/event-reducer.ts`
## Subagents and task delegation
OpenCode models delegation as child sessions, not inline mystery behavior.
- the `task` tool chooses a non-primary agent and creates or resumes a child session
- `task_id` is really the child session ID, so delegation is resumable
- subagent intent is visible in the parent transcript as a tool call and related subtask part
- tool permissions for subagents are intentionally constrained
This is much closer to what Happy should want than flattening delegated work into
one chat thread without identity.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/agent/agent.ts`
## Task tracking / todos
Todos are a first-class session store.
- todo state is separate from transcript parts
- write behavior is whole-list replacement with ordered rows
- schema is intentionally tiny: `content`, `status`, `priority`
- UI gets a proper todo dock instead of scraping plans from text
This is a strong design signal for Happy: todos should be their own state channel.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/todo.ts`
## Modes, models, and permissions
OpenCode treats these as explicit state, not just prompt flavor.
- newer logic keys on `agent`, `providerID`, `modelID`, and `variant`
- plan/build/general/explore are modeled as agent choices more than a free-form mode string
- permissions are first-class requests with `id`, `sessionID`, `permission`, patterns, metadata, and decision mode
- decisions can be `once`, `always`, or `reject`
- permission rules are pattern-based and support auto-unblocking pending matching requests
This is a good reference for Happy's app model even if Happy keeps its own policy
engine.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/evaluate.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/permission.tsx`
## Sandbox and isolation
OpenCode is weaker on true sandboxing than Codex.
- the main isolation story is workspace and git worktree separation
- extra workspaces are treated like sandboxes in product language
- the server routes operations by workspace directory
- there is not much evidence here of strong OS-level sandbox policy comparable to Codex
Takeaway for Happy: copy the workspace isolation ideas, not the lack of a deeper
sandbox layer.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/worktree/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace.ts`
## Sync and server architecture
This is probably the most valuable follow-up topic.
- the app listens to a global SSE stream
- events are then fanned into per-directory caches and reducers
- full session history is fetched separately when needed
- the client batches and coalesces updates instead of repainting on every raw event
- the control plane already looks ready for non-local workspace adaptors later
This is a much better direction for Happy than a single opaque message pipeline.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/sse.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/context/sync.tsx`
## Context debug surface
The user feedback here is correct and important.
- clicking the context usage field opens a context tab
- that tab shows a useful breakdown of model/provider/context usage
- it also exposes raw message-plus-parts state, effectively a built-in protocol debugger
Happy should copy this idea.
Primary source files:
- `../happy-adjacent/research/opencode/packages/app/src/components/session-context-usage.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
## What Happy should steal
- envelope + typed-parts transcript structure
- child-session subagent model with resumable IDs
- first-class todo and permission stores
- event stream for freshness plus fetch API for hydration
- built-in raw context/messages inspector in the UI
@@ -0,0 +1,818 @@
# OpenCode Runtime Tracing
Reviewed on 2026-03-21.
This is the protocol-level pass that the first writeup was missing.
The goal here was simple: run OpenCode from source, copy auth from the global
install into an isolated temp root, drive a real sample project, and only trust:
- exact request bodies sent to OpenCode
- exact JSON responses from OpenCode endpoints
- raw `/event` SSE logs from OpenCode
- OpenCode source code
For the Happy side of the comparison, this document only trusts Happy code.
## Setup That Was Actually Used
OpenCode source checkout:
- `../happy-adjacent/research/opencode`
- commit `2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`
Sample project:
- `/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project`
Isolated runtime root:
- `/tmp/opencode-trace-dev.ptZAVJ`
Auth source and copy:
- source: `~/.local/share/opencode/auth.json`
- copied into:
`/tmp/opencode-trace-dev.ptZAVJ/share/opencode/auth.json`
- only provider key present in the copied auth file was `openai`
Server command:
```bash
XDG_DATA_HOME=/tmp/opencode-trace-dev.ptZAVJ/share \
XDG_CACHE_HOME=/tmp/opencode-trace-dev.ptZAVJ/cache \
XDG_CONFIG_HOME=/tmp/opencode-trace-dev.ptZAVJ/config \
XDG_STATE_HOME=/tmp/opencode-trace-dev.ptZAVJ/state \
OPENCODE_CONFIG_DIR=/tmp/opencode-trace-dev.ptZAVJ/profile \
OPENCODE_DB=/tmp/opencode-trace-dev.ptZAVJ/share/opencode/opencode.db \
bun run --cwd packages/opencode --conditions=browser src/index.ts \
serve --hostname 127.0.0.1 --port 4098 --print-logs --log-level DEBUG
```
Important source files behind that setup:
- `../happy-adjacent/research/opencode/packages/opencode/src/auth/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/server.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/routes/experimental.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/prompt.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
## What Counts As “Real” Here
The useful protocol evidence is not one big hidden RPC envelope. It is spread
across four surfaces:
1. The request bodies we send to `POST /session/:id/prompt_async`
2. The persisted message rows from `GET /session/:id/message`
3. The live patch stream from `GET /event`
4. The control-plane responses such as `/path`, `/permission`,
`/session/:id/children`, `/experimental/worktree`, and
`/experimental/workspace`
That distinction matters for Happy. OpenCode does **not** have one single
append-only transcript stream that already contains everything the UI needs.
It has:
- stable message rows with typed parts
- live patch events against those rows
- first-class side-channel events for permissions and session state
- separate workspace/worktree routing outside the transcript
## Flow 0: Directory Routing, Worktree, Workspace, “Sandbox”
Before touching prompts, I verified how the server scopes requests.
The key routing input is the header:
```http
x-opencode-directory: /Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project
```
Real `GET /path` response:
```json
{
"home": "/Users/kirilldubovitskiy",
"state": "/tmp/opencode-trace-dev.ptZAVJ/state/opencode",
"config": "/tmp/opencode-trace-dev.ptZAVJ/config/opencode",
"worktree": "/Users/kirilldubovitskiy/projects/happy",
"directory": "/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project"
}
```
Real empty listings for the current project:
```json
GET /experimental/worktree -> []
GET /experimental/workspace -> []
```
What that means:
- project scoping is a request-routing concern, not a transcript concern
- the current project lives under a `worktree` root and a narrower `directory`
- worktrees and workspaces are explicit control-plane resources
- none of this shows up as transcript parts like `type: "sandbox"` or
`type: "workspace"`
So when OpenCode product language says “sandbox”, the concrete implementation
here is mostly:
- directory scoping
- optional workspace routing
- optional git worktree management
It is **not** the same kind of OS/filesystem/network sandbox policy that Happy
already has code for in `packages/happy-cli/src/sandbox/config.ts`.
## Flow 1: Permission Ask Around `apply_patch`
This was the cleanest real trace because it exercised:
- user prompt creation
- assistant step lifecycle
- reasoning
- tool call
- permission request
- permission reply
- file edit side effects
- final assistant follow-up
### 1. Session creation
I created the session with an explicit edit permission rule that forces asks:
```json
POST /session
{
"title": "trace permission ask",
"permission": [
{
"permission": "edit",
"pattern": "*",
"action": "ask"
}
]
}
```
### 2. Prompt body sent
```json
POST /session/{sessionID}/prompt_async
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Create a new file named TRACE_PERMISSION.md in the current directory with exactly one line: rat permission trace. Then reply with one short sentence."
}
]
}
```
### 3. User message persisted
```json
{
"info": {
"role": "user",
"id": "msg_d0f8263b50016s8bKlZ36Te52c",
"sessionID": "ses_2f07d9c71ffeikGiLoOKqF2Evb"
},
"parts": [
{
"type": "text",
"text": "Create a new file named TRACE_PERMISSION.md in the current directory with exactly one line: rat permission trace. Then reply with one short sentence."
}
]
}
```
### 4. Live permission event
Real SSE event:
```json
{
"type": "permission.asked",
"properties": {
"id": "per_d0f826ef60011FReJ16cM2d0MK",
"sessionID": "ses_2f07d9c71ffeikGiLoOKqF2Evb",
"permission": "edit",
"patterns": [
"environments/lab-rat-todo-project/TRACE_PERMISSION.md"
],
"always": ["*"],
"tool": {
"messageID": "msg_d0f8263b9001UcnDrNrnbyYeyT",
"callID": "call_uJj6gIQfIPpSoBV9oOWBT7cF"
},
"metadata": {
"filepath": "environments/lab-rat-todo-project/TRACE_PERMISSION.md",
"files": [
{
"relativePath": "environments/lab-rat-todo-project/TRACE_PERMISSION.md",
"type": "add",
"after": "rat permission trace\n",
"additions": 1,
"deletions": 0
}
]
}
}
}
```
This is important. OpenCode permission is not just “tool X wants approval”.
The request carries:
- the permission kind
- exact path patterns
- a stable request id
- linkage back to the tool call
- a ready-to-render diff payload
### 5. Assistant message around the tool call
Persisted assistant message after approval:
```json
{
"info": {
"role": "assistant",
"finish": "tool-calls",
"id": "msg_d0f8263b9001UcnDrNrnbyYeyT"
},
"parts": [
{ "type": "step-start", "snapshot": "dfd3f0873ec51c2ddbf0b6b79acc154e5ab15c5d" },
{
"type": "reasoning",
"text": "**Creating a file**\n\nI need to create a file...",
"metadata": {
"openai": {
"itemId": "rs_...",
"reasoningEncryptedContent": "gAAAAA..."
}
}
},
{
"type": "tool",
"callID": "call_uJj6gIQfIPpSoBV9oOWBT7cF",
"tool": "apply_patch",
"state": {
"status": "completed",
"input": {
"patchText": "*** Begin Patch\n*** Add File: TRACE_PERMISSION.md\n+rat permission trace\n*** End Patch"
},
"output": "Success. Updated the following files:\nA environments/lab-rat-todo-project/TRACE_PERMISSION.md"
}
},
{ "type": "step-finish", "reason": "tool-calls" }
]
}
```
Then OpenCode emitted a second assistant message with the final visible text:
```json
{
"info": {
"role": "assistant",
"finish": "stop"
},
"parts": [
{ "type": "step-start" },
{ "type": "text", "text": "Done." },
{ "type": "step-finish", "reason": "stop" }
]
}
```
### 6. The live event sequence actually seen
The raw SSE stream showed this order:
1. `session.created`
2. `message.updated` for the user message
3. `message.part.updated` for the user `text` part
4. `session.status` -> `busy`
5. `message.updated` for the assistant message shell
6. `message.part.updated` -> `step-start`
7. `message.part.updated` -> `reasoning`
8. many `message.part.delta` chunks streaming the reasoning text
9. `message.part.updated` -> tool part with `status: "pending"`
10. `permission.asked`
11. `permission.replied`
12. `file.edited`
13. `file.watcher.updated`
14. `message.part.updated` -> tool part `status: "running"`
15. `message.part.updated` -> tool part `status: "completed"`
16. `message.part.updated` -> `step-finish`
17. second assistant message with final `text`
18. `session.status` -> `idle`
The file really was created on disk:
```text
TRACE_PERMISSION.md: rat permission trace
```
## Flow 2: Media Input Failure Path
The failure case is useful because it shows what OpenCode stores before the
provider rejects the request.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "tiny.png",
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0XQAAAAASUVORK5CYII="
}
]
}
```
### 2. User message persisted
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "tiny.png",
"url": "data:image/png;base64,iVBORw0K..."
}
]
}
```
### 3. Assistant error persisted
```json
{
"info": {
"role": "assistant",
"error": {
"name": "APIError",
"data": {
"message": "The image data you provided does not represent a valid image. Please check your input and try again.",
"statusCode": 400,
"isRetryable": false,
"metadata": {
"url": "https://api.openai.com/v1/responses"
}
}
}
},
"parts": []
}
```
The live stream also emitted `session.error`.
This is a good example of OpenCode being honest: it stores the user-side file
part exactly as sent, and the failure becomes assistant/session error state
instead of getting normalized away.
## Flow 3: Media Input Success Path
The successful media path looked different because the input URL was a local
`file://...` URL, which OpenCode resolved itself.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "logo.png",
"url": "file:///Users/kirilldubovitskiy/projects/happy/logo.png"
}
]
}
```
### 2. User message persisted after OpenCode normalized it
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "text",
"synthetic": true,
"text": "Called the Read tool with the following input: {\"filePath\":\"/Users/kirilldubovitskiy/projects/happy/logo.png\"}"
},
{
"type": "file",
"mime": "image/png",
"filename": "logo.png",
"url": "data:image/png;base64,iVBORw0K..."
}
]
}
```
That is a real behavior from `session/prompt.ts`: OpenCode injects a synthetic
text part describing the read, then stores the media itself as a `file` part.
### 3. Assistant response persisted
```json
{
"info": {
"role": "assistant",
"finish": "stop"
},
"parts": [
{ "type": "step-start" },
{
"type": "reasoning",
"text": "",
"metadata": {
"openai": {
"itemId": "rs_...",
"reasoningEncryptedContent": "gAAAAA..."
}
}
},
{
"type": "text",
"text": "A cute cartoon otter is lounging in water while using a laptop."
},
{ "type": "step-finish", "reason": "stop" }
]
}
```
### 4. What the live stream proves
The `/event` log showed:
- `message.part.updated` for the synthetic read text
- `message.part.updated` for the `file` part
- reasoning creation
- streamed `message.part.delta` chunks for the assistant text
- `session.status` returning to `idle`
So the real media story is:
- user-facing prompt part: `type: "file"`
- internal transcript expansion: synthetic `text` plus concrete `file`
- assistant-side answer: ordinary text output
## Flow 4: Subtask / Child Session / Permission Constraining
This was the most important trace for side-by-side comparison with Happy.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Find the main files in this tiny project and report back briefly."
},
{
"type": "agent",
"name": "explore"
}
]
}
```
### 2. User message persisted
OpenCode did **not** store just the raw `agent` part. It rewrote the user
message into:
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Find the main files in this tiny project and report back briefly."
},
{
"type": "agent",
"name": "explore"
},
{
"type": "text",
"synthetic": true,
"text": " Use the above message and context to generate a prompt and call the task tool with subagent: explore"
}
]
}
```
Again, this is a real `session/prompt.ts` behavior, not a guess.
### 3. Parent assistant message and `task` tool part
```json
{
"info": {
"role": "assistant",
"finish": "tool-calls",
"id": "msg_d0f855de2001b0RbgA3JGA5lzk"
},
"parts": [
{ "type": "step-start" },
{
"type": "reasoning",
"text": "**Generating a task prompt**\n\nI need to call the task tool with the subagent explore..."
},
{
"type": "tool",
"callID": "call_OqUEr7ccnf3zEb2rgLYDp5uR",
"tool": "task",
"state": {
"status": "completed",
"input": {
"description": "Find main project files",
"prompt": "Inspect the repository and identify the main files in this tiny project. Focus on the key entry points, config files, and any top-level files that define how the project runs. Return a brief list of the most important files with one short note each about what they appear to do. Keep it concise and do not modify anything.",
"subagent_type": "explore"
},
"output": "task_id: ses_2f07a8dd6ffeRc23sIIgM4ZpMT (for resuming to continue this task if needed)\n\n<task_result>\nMain files:\n\n- `.../index.html` ...\n- `.../app.js` ...\n- `.../styles.css` ...\n- `.../README.md` ...\n\nNo build/config files are present; it looks like a simple frontend-only static app.\n</task_result>",
"metadata": {
"sessionId": "ses_2f07a8dd6ffeRc23sIIgM4ZpMT",
"model": {
"modelID": "gpt-5.4-mini",
"providerID": "openai"
}
}
}
},
{ "type": "step-finish", "reason": "tool-calls" }
]
}
```
Two key points:
- the parent transcript stores the `task` tool invocation and result
- resumability is by child session id, returned as `task_id`
### 4. Child session actually created
Real `GET /session/{parentID}/children` response:
```json
[
{
"id": "ses_2f07a8dd6ffeRc23sIIgM4ZpMT",
"parentID": "ses_2f07aa25affeqiZHSnBiN8pSyG",
"title": "Find main project files (@explore subagent)",
"directory": "/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project",
"permission": [
{ "permission": "todowrite", "pattern": "*", "action": "deny" },
{ "permission": "todoread", "pattern": "*", "action": "deny" },
{ "permission": "task", "pattern": "*", "action": "deny" }
]
}
]
```
This is the cleanest proof that OpenCode subagents are child sessions with
their own identity and their own permission rules.
### 5. Live stream across parent and child
The raw `/event` stream showed:
1. parent user message created
2. parent assistant `step-start`
3. parent reasoning deltas
4. parent `tool` part for `task` becomes `pending`
5. `session.created` for the child session
6. parent `tool` part becomes `running`
7. child user message appears in the child session
8. child assistant message starts
9. child reasoning deltas stream
10. child session reaches `idle`
11. parent `tool` part becomes `completed`
So OpenCode is not faking subagents inside one flat message lane. It uses:
- parent session transcript
- child session transcript
- parent tool metadata linking to the child session id
## Side By Side With Happys Current Code
This section uses only Happy code, not Happy runtime traces.
| Topic | OpenCode, proven by logs/code | Happy, proven by code |
|---|---|---|
| Outer envelope | message rows already have top-level `info` plus ordered typed `parts` | `packages/happy-wire/src/messages.ts` still wraps the newer format as `role: "session"` with inner `content: sessionEnvelope` |
| Event discriminant | parts use top-level `type` like `text`, `reasoning`, `tool`, `file`, `agent`, `subtask`, `step-start` | `packages/happy-wire/src/sessionProtocol.ts` still nests event type under `ev.t` |
| Permissions | live `permission.asked` and `permission.replied` events carry tool linkage and diff metadata | `packages/happy-app/sources/sync/reducer/reducer.ts` still reconstructs permission state by merging transcript-ish messages with encrypted `agentState` |
| Subagents | real child sessions with `parentID`; `task_id` is resumable child session id | `packages/happy-wire/src/sessionProtocol.ts` only has optional `subagent` on envelopes, not child-session identity plus transcript-level linkage |
| Media | user `file` part plus synthetic helper `text`; successful local files become concrete `data:` URLs | `packages/happy-wire/src/sessionProtocol.ts` only has one `file` event shape; plan doc proposes direct `photo` / `video` / `file` variants |
| Sandbox / isolation | routing is by directory/workspace and optional worktrees; “sandbox” is mostly worktree/workspace language | `packages/happy-cli/src/sandbox/config.ts` already has concrete filesystem allow/deny rules plus network modes |
| Client complexity | OpenCode reducers merge live patches into already-typed message rows | `packages/happy-app/sources/sync/typesRaw.ts` and `packages/happy-app/sources/sync/reducer/reducer.ts` still carry legacy families plus complex reconstruction logic |
The main conclusion is blunt:
- OpenCode has the cleaner transcript shape
- Happy has the stronger real sandbox config
- Happys current reducer complexity is the strongest argument against keeping
multiple plaintext payload families alive
## What This Means For `provider-envelope-redesign.md`
Current planning context from `docs/plans/provider-envelope-redesign.md` still
looks right:
- the p6 envelope redesign work is in the dirty worktree, not committed branch
history
- that work already proved useful cleanup moves:
`type` at top level, no outer `role: "session"`, `parentId`/`agentId`,
transcript permissions, direct media variants
- the current proposal in that plan doc is still the plan of record
- OpenCode raw protocol shape remains the strongest outside reference to
evaluate before locking a new steady-state Happy schema
- Claudes older transcript-like format is still a plausible fallback if the
simplest stable model turns out to be closer to that history
OpenCode does **not** argue for copying ACP wrapper behavior. It argues for
copying the raw transcript shape:
- stable message rows
- typed parts
- explicit permission objects
- explicit child-session identity
- clear separation between transcript state and live patch transport
## The Hard Part For Happy: Encrypted Storage
This is where OpenCode and Happy diverge most.
OpenCode can happily keep canonical message rows and patch parts over time
because its storage layer sees plaintext session state.
Happy stores opaque encrypted blobs. That means copying OpenCode literally
forces a storage decision.
### Option A: Append-only canonical transcript events
Store already-normalized encrypted records that are durable on their own.
Example mental model:
```json
{ "kind": "agent-event", "type": "tool-start", ... }
{ "kind": "agent-event", "type": "permission-request", ... }
{ "kind": "agent-event", "type": "tool-end", ... }
```
Pros:
- keeps storage immutable
- refetch is simple
- matches Happys current transport assumptions
- avoids replaying raw deltas to rebuild a usable transcript
Cons:
- not a literal copy of OpenCodes patching model
- either start/end events stay separate forever, or the client must derive
“latest state” views for UI convenience
### Option B: Patch canonical encrypted message rows
Keep stable encrypted message ids, but rewrite the encrypted payload when parts
gain new state so refetch returns the newest canonical snapshot.
Example mental model:
```json
{
"messageId": "msg_123",
"parts": [
{ "type": "tool", "state": { "status": "pending" } }
]
}
```
Later rewritten as:
```json
{
"messageId": "msg_123",
"parts": [
{
"type": "tool",
"state": {
"status": "completed",
"input": { ... },
"output": "..."
}
}
]
}
```
Pros:
- closest to OpenCodes server-side model
- refetch gives the latest canonical state directly
- fewer client-side reconstruction problems
Cons:
- encrypted message rows become mutable
- sync/versioning gets more delicate
- you lose pure append-only history unless you also keep a shadow event log
### Option C: Append raw patch events and reconstruct on the client
Store the raw stream and rebuild message state client-side.
Example mental model:
```json
{ "type": "message.updated", ... }
{ "type": "message.part.updated", ... }
{ "type": "message.part.delta", ... }
{ "type": "permission.asked", ... }
```
Pros:
- closest to the live OpenCode stream
- fully immutable if every patch is appended
Cons:
- this is exactly the direction most likely to recreate Happys current reducer
pain
- refetch requires replay/materialization
- encrypted storage plus legacy format support makes this the highest-complexity
option
### Recommendation
If Happy borrows from OpenCode, it should probably steal the **shape** but not
the entire persistence strategy.
The strongest options are:
1. append-only **canonical** events
2. patchable **canonical** message snapshots
The weakest option is:
- raw patch-stream reconstruction as the primary durable format
That would preserve too much of the complexity we are trying to get rid of.
+32
View File
@@ -0,0 +1,32 @@
# OpenCode Sources
Reviewed on 2026-03-21.
- repo: `https://github.com/sst/opencode`
- checkout: `../happy-adjacent/research/opencode`
- commit: `2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`
## Primary files inspected
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/prompt.ts`
- `../happy-adjacent/research/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/evaluate.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/server.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/routes/experimental.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/auth/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/sse.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync/event-reducer.ts`
- `../happy-adjacent/research/opencode/packages/app/src/components/session-context-usage.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
## Notes
- The context/debug surface is not a side detail; it is one of the strongest product ideas in the repo.
- The server split deserves deeper follow-up work after this first pass.
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env bash
#
# trace-opencode.sh — reusable harness for tracing OpenCode's protocol
#
# Sets up an isolated OpenCode server, runs scripted flows against it,
# and captures raw request/response/SSE logs.
#
# Usage:
# ./scripts/trace-opencode.sh [flow ...]
#
# flows: permission, media, subtask, question, todo, all (default: all)
#
# Prerequisites:
# - OpenCode source checkout at ../happy-adjacent/research/opencode
# - bun installed
# - Auth file at ~/.local/share/opencode/auth.json (with at least one provider key)
#
# Output:
# docs/competition/opencode/traces/<timestamp>/
# setup.json — runtime config used
# flow-<name>.json — per-flow request/response/events log
# sse-raw.log — raw SSE stream dump
#
set -euo pipefail
# ── Config ──────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OPENCODE_SRC="${OPENCODE_SRC:-$REPO_ROOT/../happy-adjacent/research/opencode}"
AUTH_SOURCE="${AUTH_SOURCE:-$HOME/.local/share/opencode/auth.json}"
LAB_RAT_DIR="$REPO_ROOT/environments/lab-rat-todo-project"
PORT="${OPENCODE_TRACE_PORT:-0}"
PROVIDER="${OPENCODE_TRACE_PROVIDER:-openai}"
MODEL="${OPENCODE_TRACE_MODEL:-gpt-4.1-mini}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
TRACE_DIR="$REPO_ROOT/docs/competition/opencode/traces/$TIMESTAMP"
TMPROOT=""
SERVER_PID=""
SSE_PID=""
# ── Helpers ─────────────────────────────────────────────────
cleanup() {
echo ""
echo "=== cleanup ==="
[ -n "$SSE_PID" ] && kill "$SSE_PID" 2>/dev/null || true
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
[ -n "$TMPROOT" ] && rm -rf "$TMPROOT"
echo "traces saved to: $TRACE_DIR"
}
trap cleanup EXIT
die() { echo "ERROR: $*" >&2; exit 1; }
check_prereqs() {
[ -d "$OPENCODE_SRC/packages/opencode/src" ] || die "OpenCode source not found at $OPENCODE_SRC"
command -v bun >/dev/null || die "bun not installed"
[ -f "$AUTH_SOURCE" ] || die "auth file not found at $AUTH_SOURCE"
[ -d "$LAB_RAT_DIR" ] || die "lab-rat project not found at $LAB_RAT_DIR"
command -v jq >/dev/null || die "jq not installed"
command -v curl >/dev/null || die "curl not installed"
}
# POST/GET with logging
api() {
local method="$1" path="$2" flow="$3"
shift 3
local url="http://127.0.0.1:$PORT$path"
local log_file="$TRACE_DIR/flow-${flow}.jsonl"
local response
if [ "$method" = "GET" ]; then
response=$(curl -sS -w '\n{"_http_code":%{http_code}}' \
-H "x-opencode-directory: $LAB_RAT_DIR" \
"$url" "$@" 2>&1)
else
response=$(curl -sS -w '\n{"_http_code":%{http_code}}' \
-X "$method" \
-H "Content-Type: application/json" \
-H "x-opencode-directory: $LAB_RAT_DIR" \
"$url" "$@" 2>&1)
fi
# Log the raw exchange
local entry
entry=$(jq -cn \
--arg method "$method" \
--arg path "$path" \
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg response "$response" \
'{method: $method, path: $path, time: $time, response: $response}')
echo "$entry" >> "$log_file"
# Return just the response body (strip the http_code line)
echo "$response" | head -n -1
}
wait_for_server() {
echo "waiting for server on port $PORT..."
local tries=0
while ! curl -s "http://127.0.0.1:$PORT/path" \
-H "x-opencode-directory: $LAB_RAT_DIR" >/dev/null 2>&1; do
tries=$((tries + 1))
[ "$tries" -gt 30 ] && die "server did not start within 30s"
sleep 1
done
echo "server ready"
}
wait_for_idle() {
local session_id="$1"
local timeout="${2:-60}"
local start=$SECONDS
echo " waiting for session $session_id to go idle..."
while true; do
local elapsed=$(( SECONDS - start ))
[ "$elapsed" -gt "$timeout" ] && die "session did not go idle within ${timeout}s"
# Check the SSE log for idle status
if grep -q "\"session.status\"" "$TRACE_DIR/sse-raw.log" 2>/dev/null; then
local last_status
last_status=$(grep "\"session.status\"" "$TRACE_DIR/sse-raw.log" | tail -1 | \
sed 's/^data: //' | jq -r '.properties.status.type // empty' 2>/dev/null || true)
if [ "$last_status" = "idle" ]; then
echo " session idle"
return 0
fi
fi
sleep 0.5
done
}
reply_permission() {
local flow="$1" reply="${2:-once}"
echo " checking for pending permissions..."
sleep 1 # give it a moment
local perms
perms=$(api GET "/permission" "$flow")
local perm_id
perm_id=$(echo "$perms" | jq -r '.[0].id // empty' 2>/dev/null || true)
if [ -n "$perm_id" ]; then
echo " replying '$reply' to permission $perm_id"
api POST "/permission/$perm_id/reply" "$flow" \
-d "{\"reply\":\"$reply\"}"
else
echo " no pending permissions"
fi
}
reply_question() {
local flow="$1"
echo " checking for pending questions..."
sleep 1
local questions
questions=$(api GET "/question" "$flow" 2>/dev/null || echo "[]")
local q_id
q_id=$(echo "$questions" | jq -r '.[0].id // empty' 2>/dev/null || true)
if [ -n "$q_id" ]; then
# auto-answer with the first option for each question
local answers
answers=$(echo "$questions" | jq -c '[.[0].questions[] | [.options[0].label]]')
echo " replying to question $q_id with: $answers"
api POST "/question/$q_id/reply" "$flow" \
-d "{\"answers\":$answers}"
else
echo " no pending questions"
fi
}
# ── Setup ───────────────────────────────────────────────────
check_prereqs
# Create isolated temp root
TMPROOT=$(mktemp -d /tmp/opencode-trace.XXXXXX)
mkdir -p "$TMPROOT"/{share/opencode,cache,config,state,profile}
cp "$AUTH_SOURCE" "$TMPROOT/share/opencode/auth.json"
# Create output dir
mkdir -p "$TRACE_DIR"
echo "=== OpenCode Protocol Tracing ==="
echo " source: $OPENCODE_SRC"
echo " lab-rat: $LAB_RAT_DIR"
echo " tmproot: $TMPROOT"
echo " output: $TRACE_DIR"
echo ""
# Find a free port if PORT=0
if [ "$PORT" = "0" ]; then
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
fi
# Save setup info
jq -n \
--arg opencode_src "$OPENCODE_SRC" \
--arg lab_rat "$LAB_RAT_DIR" \
--arg tmproot "$TMPROOT" \
--arg port "$PORT" \
--arg provider "$PROVIDER" \
--arg model "$MODEL" \
--arg timestamp "$TIMESTAMP" \
'{opencode_src: $opencode_src, lab_rat: $lab_rat, tmproot: $tmproot,
port: ($port | tonumber), provider: $provider, model: $model,
timestamp: $timestamp}' \
> "$TRACE_DIR/setup.json"
# ── Start server ────────────────────────────────────────────
echo "=== starting opencode server on port $PORT ==="
XDG_DATA_HOME="$TMPROOT/share" \
XDG_CACHE_HOME="$TMPROOT/cache" \
XDG_CONFIG_HOME="$TMPROOT/config" \
XDG_STATE_HOME="$TMPROOT/state" \
OPENCODE_CONFIG_DIR="$TMPROOT/profile" \
OPENCODE_DB="$TMPROOT/share/opencode/opencode.db" \
bun run --cwd "$OPENCODE_SRC/packages/opencode" --conditions=browser src/index.ts \
serve --hostname 127.0.0.1 --port "$PORT" --print-logs --log-level DEBUG \
> "$TRACE_DIR/server.log" 2>&1 &
SERVER_PID=$!
wait_for_server
# Start SSE listener
echo "=== starting SSE listener ==="
curl -sS -N "http://127.0.0.1:$PORT/event" \
-H "x-opencode-directory: $LAB_RAT_DIR" \
> "$TRACE_DIR/sse-raw.log" 2>&1 &
SSE_PID=$!
sleep 0.5
# ── Parse flow arguments ────────────────────────────────────
FLOWS=("$@")
[ ${#FLOWS[@]} -eq 0 ] && FLOWS=(all)
run_flow() {
local name="$1"
case "$name" in
all)
run_flow permission
run_flow media
run_flow subtask
run_flow todo
;;
permission)
echo ""
echo "=== Flow: permission ==="
echo " creating session with edit ask rule..."
local session
session=$(api POST "/session" permission \
-d "{\"title\":\"trace-permission\",\"permission\":[{\"permission\":\"edit\",\"pattern\":\"*\",\"action\":\"ask\"}]}")
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt..."
api POST "/session/$sid/prompt_async" permission \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Create a file named TRACE_PERM.md with exactly one line: permission trace. Then reply with one short sentence.\"}]}"
sleep 2
reply_permission permission once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" permission
echo " fetching children..."
api GET "/session/$sid/children" permission
echo " flow: permission done"
;;
media)
echo ""
echo "=== Flow: media ==="
local session
session=$(api POST "/session" media \
-d '{"title":"trace-media"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
# Test with a tiny inline PNG (1x1 red pixel)
local tiny_png="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
echo " sending prompt with inline image..."
api POST "/session/$sid/prompt_async" media \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Describe the attached image in one short sentence. Do not use any tools.\"},{\"type\":\"file\",\"mime\":\"image/png\",\"filename\":\"tiny.png\",\"url\":\"data:image/png;base64,$tiny_png\"}]}"
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" media
echo " flow: media done"
;;
subtask)
echo ""
echo "=== Flow: subtask ==="
local session
session=$(api POST "/session" subtask \
-d '{"title":"trace-subtask"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt with @explore agent..."
api POST "/session/$sid/prompt_async" subtask \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Find the main files in this tiny project and report back briefly.\"},{\"type\":\"agent\",\"name\":\"explore\"}]}"
wait_for_idle "$sid" 90
echo " fetching messages..."
api GET "/session/$sid/message" subtask
echo " fetching children..."
api GET "/session/$sid/children" subtask
echo " flow: subtask done"
;;
todo)
echo ""
echo "=== Flow: todo ==="
local session
session=$(api POST "/session" todo \
-d '{"title":"trace-todo"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt asking to create todos..."
api POST "/session/$sid/prompt_async" todo \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Look at this project and create a todo list with 3 items about improvements. Use the todowrite tool.\"}]}"
# todowrite might need permission
sleep 3
reply_permission todo once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" todo
echo " fetching todos..."
api GET "/session/$sid/todo" todo
echo " flow: todo done"
;;
question)
echo ""
echo "=== Flow: question ==="
local session
session=$(api POST "/session" question \
-d '{"title":"trace-question"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt that should trigger question tool..."
api POST "/session/$sid/prompt_async" question \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"I want to set up a database for this project. Ask me which database I prefer before proceeding. Use the question tool.\"}]}"
sleep 3
reply_question question
# might also need permission for the question tool
reply_permission question once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" question
echo " flow: question done"
;;
*)
die "unknown flow: $name (available: permission, media, subtask, todo, question, all)"
;;
esac
}
# ── Run flows ───────────────────────────────────────────────
for flow in "${FLOWS[@]}"; do
run_flow "$flow"
done
echo ""
echo "=== all flows complete ==="
echo ""
echo "Output:"
echo " $TRACE_DIR/setup.json — runtime config"
echo " $TRACE_DIR/flow-*.jsonl — per-flow request/response logs"
echo " $TRACE_DIR/sse-raw.log — raw SSE event stream"
echo " $TRACE_DIR/server.log — server stdout/stderr"
echo ""
echo "Quick inspection:"
echo " jq -s '.' $TRACE_DIR/flow-permission.jsonl | less"
echo " grep 'data:' $TRACE_DIR/sse-raw.log | jq -r '.type' | sort | uniq -c | sort -rn"