bots-garden/sidekickspublic Fork
⑂ feature/1st-release
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/sidekicks.git
git clone ssh://git@rickub.com/bots-garden/sidekicks.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP
🛟 Updated. d580a32 · on feature/1st-release · k33g · 3d ago
0003-architecture.md · 135 lines · 11.4 KBmarkdown
Blame HistoryOpen raw

ADR 0003 — Architecture, data model and events

Context

Several features overlap: an agent process serves several channels, the same ACP session feeds the conversation and the Agents panel, and the configuration file can change while agents run. Without a clear split, the frontend ends up driving agent processes directly. This ADR fixes the packages, the data model, what is stored where, and the events between Go and the UI, before the first production code (milestone M1).

Packages

Packages and dependencies

Source: docs/diagrams/packages.drawio. Blue boxes exist (end of M1), grey ones are planned; the diagram is updated whenever a package is added, removed or re-wired.

Package Responsibility Depends on
main.go (repository root) main: flags (--config), Wails application, services, events registration, window. No logic. internal/app, internal/events, Wails
internal/app The services bound to the frontend (AgentService, ConfigService in M1; ChannelService, WorkspaceService later) and the glue between configuration and agents. Translates between UI calls and the domain; the only package the UI sees. Does not import Wails. domain packages, internal/events
internal/events Event names, payload types and the Emitter interface the domain emits through. —
internal/channels Channels, members, messages, routing of a user message to the agents it addresses, @mention and /agent:command parsing, context re-injection. agents, store
internal/agents One manager owning every agent process: lazy start, idle stop, maxRunningAgents, state machine, stderr logs. Opens ACP sessions on behalf of channels. acp, config
internal/acp Wraps coder/acp-go-sdk: connection, initialize, sessions, prompt turns, permission requests. Exposes Sidekicks types; SDK types never leave it. ACP SDK
internal/config Load, validate (schema + cross-entry rules), resolve paths, watch, save keeping comments. schema, yaml
schema The configuration JSON Schema and ValidateConfig. Public (not internal/) because the editor's validation and external tools may use it. jsonschema, yaml
internal/store Pebble: channels, memberships, messages. Knows nothing about ACP. Pebble v2
internal/workspace Workspace interface (list, read, write, remove, watch, exec with PTY) and its local, container, sbx implementations. creack/pty, docker, sbx

Rules:

  • Dependencies point downwards only; no cycle. channels uses agents, never the opposite: an agent does not know about channels.
  • Each domain package is unit-tested with fakes of the package below it (a fake ACP agent built with the SDK's agent side, an in-memory store, a fake workspace). Tests with real agents carry the realagents build tag and go through scripts/agents.sh.
  • Errors are returned with context (fmt.Errorf("start agent %s: %w", …)); internal/app turns them into messages for people.

Configuration vs. stored state

Where Edited by Examples
What exists the YAML file (spec) the user (Settings panel, any editor) agents, declared channels, limits, UI preferences
What happens Pebble, under <user data dir>/sidekicks/<config id>/ the application channels created in the UI, members, messages, ACP session ids
Application preferences a small JSON file next to the Pebble folder the application last configuration opened, window layout

<config id> is a short hash of the configuration file's absolute path: two configurations never mix their histories (issue #19).

Channels have one home: Pebble. A channel declared in YAML is created in Pebble at startup if no channel of that name exists; afterwards it is an ordinary channel. Removing it from YAML does not delete it. This keeps the YAML a description, never a database.

Data model

Agent          (from the configuration — not stored)
  name, title, icon, description, command, args, env, workspace, runtime

Channel        id (ULID), name, description, createdAt, updatedAt
Membership     channelId, agentName, joinedAt, acpSessionId?, lastSeenMessageId?
Message        id (ULID), channelId, author {kind: user|agent|system, agentName?},
               parts[], createdAt, turn {stopReason?, error?, usage?}
Part           text (Markdown) | toolCall {id, kind, title, status, input, output}
               | permission {toolCallId, options, choice} | plan {entries}
  • ULIDs sort by creation time, so a key range scan returns messages in order without an index.
  • lastSeenMessageId is how routing knows which messages an agent has not seen yet (decision 3 of ADR 0001, issue #23): when it is mentioned, the messages after it are added to its prompt.
  • acpSessionId is stored but not reusable with mm (no loadSession); after a restart a new session is opened and the context is re-injected (issue #25). It is kept for agents that support session/load.

Pebble keys

v                                    → schema version of the store (1)
channel/<channelId>                  → Channel (JSON)
channel-name/<name>                  → channelId          (uniqueness, lookup)
member/<channelId>/<agentName>       → Membership (JSON)
msg/<channelId>/<messageId>          → Message (JSON)

A message is written when the turn ends (stop reason or error), not for each streamed chunk; while it streams it lives in memory only. Losing an unfinished answer on a crash is acceptable; writing to disk 50 times per second is not.

Events from Go to the UI

Typed with application.RegisterEvent[T] (ADR 0002), emitted only by internal/app.

Event Payload When
config:changed the agents and channels summary, or validation errors the file was reloaded
agent:state `{agent, state: stopped starting
agent:log {agent, line} a stderr line (only while the Agents panel shows it)
channel:changed Channel + members created, renamed, deleted, a member joined
message:started {channelId, messageId, author} a turn starts
message:delta {channelId, messageId, text?, part?} a chunk, a tool call or its update, a plan
message:finished the complete Message stop reason or error
permission:requested {requestId, channelId, agent, toolCall, options} the agent asks (the UI answers with ChannelService.AnswerPermission)
commands:changed {channelId, agent, commands} available_commands_update
usage:changed {channelId, agent, used, size} usage_update
terminal:data {terminalId, data} PTY output (issue #31)
workspace:changed {agent, path} a file changed in a watched workspace

The frontend renders Markdown at most once per animation frame (ADR 0002), so message:delta can be emitted for every ACP chunk without batching on the Go side; batching is added only if measurements on macOS require it (issue #13).

Flow of a user message

UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ internal/app
  ─▶ channels.Route: mentions → [garfield], command → "/idioms Il pleut"
  ─▶ agents.Session(channel, garfield): start mm if needed (limit!), session/new if needed
  ─▶ prompt = unseen channel messages + "/idioms Il pleut"
  ─▶ acp.Prompt … session/update → message:delta events … stopReason
  ─▶ store: Message saved, lastSeenMessageId updated ─▶ message:finished

Amendment 1 — what M1 changed (2026-10-29)

  • The main package is at the repository root, not in cmd/sidekicks. The Taskfiles Wails generates build . and generate the bindings from it on every platform; moving main would mean editing about ten generated commands that wails3 update build-assets overwrites. The root package stays a thin main.go.
  • Only main.go imports Wails. internal/app exposes plain Go services and emits through internal/events.Emitter; main.go binds the services and plugs the Wails event manager in. internal/app is therefore tested without a window or cgo.
  • internal/events is a new package: the event names and payloads below, the Emitter interface, and a Recorder for tests.
  • When the limit is reached, starting an agent first stops the least recently used idle agent; only when every running agent is busy is the start refused.
  • Events of M1: config:changed, agent:state, agent:log. The others of the catalogue arrive with their features.

Amendment 2 — what M2 changed (2026-10-29)

Milestone M2 adds direct conversations before channels: internal/conversations (one in-memory conversation per agent), internal/prefs (last opened and recent configuration files), the ChatService, the Settings methods of ConfigService, and six conversation events keyed by agent (message:updated, message:delta, turn:changed, permission:requested, permission:resolved, usage:changed). Details and reasons: ADR 0004. The channel events of the catalogue above remain the M3 target.

Amendment 3 — what M3 changed (2026-10-29)

internal/channels (as planned in the package table) replaces internal/conversations: a direct conversation is a channel with one member. internal/store holds channels, members, messages and a small key-value space (value/<key>: the commands each agent announced); a direct conversation is indexed by direct/<agent> instead of a name. The database lives under <user config dir>/sidekicks/data/<hash of the configuration path>/. Events keep the M2 names, keyed by channel, plus channels:changed (the whole list) and commands:changed; message:started of the catalogue above is message:updated, as in M2. ChannelService replaces ChatService. channels uses agents through the AgentSource interface (Acquire, Definition), never the opposite. Details and reasons: ADR 0006.

Amendment 4 — what M4 changed (2026-10-29)

internal/workspace (as planned in the package table) reaches an agent's folder: Local (os, fsnotify, creack/pty) and Container (docker exec); sbx is refused with ErrNotSupported until its own issue. It also holds Terminals, the open shells and their output events. internal/app adds WorkspaceService and TerminalService; events workspace:changed, terminal:output, terminal:exit. workspace depends on config (the runtime) and events, nothing else. Details and reasons: ADR 0007.

Consequences

  • M1 creates main.go, internal/app, internal/events, internal/config, internal/acp, internal/agents (issues #8–#13; see Amendment 1).
  • The diagram and this table are part of the definition of done of any change that adds or re-wires a package.
  • Pebble v2 (github.com/cockroachdb/pebble/v2) is the store; its use is confined to internal/store.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# ADR 0003 — Architecture, data model and events

- **Status**: accepted — amended by milestone M1 (Amendment 1), milestone M2 (Amendment 2), milestone M3 (Amendment 3) and milestone M4 (Amendment 4)
- **Date**: 2026-10-29
- **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml)
- **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md)

## Context

Several features overlap: an agent process serves several channels, the same ACP session feeds the conversation and the Agents panel, and the configuration file can change while agents run. Without a clear split, the frontend ends up driving agent processes directly. This ADR fixes the packages, the data model, what is stored where, and the events between Go and the UI, before the first production code (milestone M1).

## Packages

![Packages and dependencies](../diagrams/packages.png)

Source: [docs/diagrams/packages.drawio](../diagrams/packages.drawio). Blue boxes exist (end of M1), grey ones are planned; the diagram is updated whenever a package is added, removed or re-wired.

| Package | Responsibility | Depends on |
| --- | --- | --- |
| `main.go` (repository root) | `main`: flags (`--config`), Wails application, services, events registration, window. No logic. | `internal/app`, `internal/events`, Wails |
| `internal/app` | The services bound to the frontend (`AgentService`, `ConfigService` in M1; `ChannelService`, `WorkspaceService` later) and the glue between configuration and agents. Translates between UI calls and the domain; **the only package the UI sees**. Does not import Wails. | domain packages, `internal/events` |
| `internal/events` | Event names, payload types and the `Emitter` interface the domain emits through. | — |
| `internal/channels` | Channels, members, messages, routing of a user message to the agents it addresses, `@mention` and `/agent:command` parsing, context re-injection. | `agents`, `store` |
| `internal/agents` | One manager owning every agent process: lazy start, idle stop, `maxRunningAgents`, state machine, stderr logs. Opens ACP sessions on behalf of channels. | `acp`, `config` |
| `internal/acp` | Wraps `coder/acp-go-sdk`: connection, `initialize`, sessions, prompt turns, permission requests. Exposes Sidekicks types; **SDK types never leave it**. | ACP SDK |
| `internal/config` | Load, validate (schema + cross-entry rules), resolve paths, watch, save keeping comments. | `schema`, yaml |
| `schema` | The configuration JSON Schema and `ValidateConfig`. Public (not `internal/`) because the editor's validation and external tools may use it. | jsonschema, yaml |
| `internal/store` | Pebble: channels, memberships, messages. Knows nothing about ACP. | Pebble v2 |
| `internal/workspace` | `Workspace` interface (list, read, write, remove, watch, exec with PTY) and its `local`, `container`, `sbx` implementations. | creack/pty, docker, sbx |

Rules:

- Dependencies point downwards only; no cycle. `channels` uses `agents`, never the opposite: an agent does not know about channels.
- Each domain package is unit-tested with fakes of the package below it (a fake ACP agent built with the SDK's agent side, an in-memory store, a fake workspace). Tests with real agents carry the `realagents` build tag and go through `scripts/agents.sh`.
- Errors are returned with context (`fmt.Errorf("start agent %s: %w", …)`); `internal/app` turns them into messages for people.

## Configuration vs. stored state

| | Where | Edited by | Examples |
| --- | --- | --- | --- |
| What exists | the YAML file ([spec](../en/reference/configuration.md)) | the user (Settings panel, any editor) | agents, declared channels, limits, UI preferences |
| What happens | Pebble, under `<user data dir>/sidekicks/<config id>/` | the application | channels created in the UI, members, messages, ACP session ids |
| Application preferences | a small JSON file next to the Pebble folder | the application | last configuration opened, window layout |

`<config id>` is a short hash of the configuration file's absolute path: two configurations never mix their histories (issue #19).

**Channels** have one home: Pebble. A channel declared in YAML is created in Pebble at startup if no channel of that name exists; afterwards it is an ordinary channel. Removing it from YAML does not delete it. This keeps the YAML a description, never a database.

## Data model

```text
Agent          (from the configuration — not stored)
  name, title, icon, description, command, args, env, workspace, runtime

Channel        id (ULID), name, description, createdAt, updatedAt
Membership     channelId, agentName, joinedAt, acpSessionId?, lastSeenMessageId?
Message        id (ULID), channelId, author {kind: user|agent|system, agentName?},
               parts[], createdAt, turn {stopReason?, error?, usage?}
Part           text (Markdown) | toolCall {id, kind, title, status, input, output}
               | permission {toolCallId, options, choice} | plan {entries}
```

- **ULIDs** sort by creation time, so a key range scan returns messages in order without an index.
- `lastSeenMessageId` is how routing knows which messages an agent has not seen yet (decision 3 of ADR 0001, issue #23): when it is mentioned, the messages after it are added to its prompt.
- `acpSessionId` is stored but **not reusable with mm** (no `loadSession`); after a restart a new session is opened and the context is re-injected (issue #25). It is kept for agents that support `session/load`.

### Pebble keys

```text
v                                    → schema version of the store (1)
channel/<channelId>                  → Channel (JSON)
channel-name/<name>                  → channelId          (uniqueness, lookup)
member/<channelId>/<agentName>       → Membership (JSON)
msg/<channelId>/<messageId>          → Message (JSON)
```

A message is written when the turn ends (stop reason or error), not for each streamed chunk; while it streams it lives in memory only. Losing an unfinished answer on a crash is acceptable; writing to disk 50 times per second is not.

## Events from Go to the UI

Typed with `application.RegisterEvent[T]` (ADR 0002), emitted only by `internal/app`.

| Event | Payload | When |
| --- | --- | --- |
| `config:changed` | the agents and channels summary, or validation errors | the file was reloaded |
| `agent:state` | `{agent, state: stopped|starting|ready|busy|error, error?}` | every state transition (issue #11) |
| `agent:log` | `{agent, line}` | a stderr line (only while the Agents panel shows it) |
| `channel:changed` | Channel + members | created, renamed, deleted, a member joined |
| `message:started` | `{channelId, messageId, author}` | a turn starts |
| `message:delta` | `{channelId, messageId, text?, part?}` | a chunk, a tool call or its update, a plan |
| `message:finished` | the complete Message | stop reason or error |
| `permission:requested` | `{requestId, channelId, agent, toolCall, options}` | the agent asks (the UI answers with `ChannelService.AnswerPermission`) |
| `commands:changed` | `{channelId, agent, commands}` | `available_commands_update` |
| `usage:changed` | `{channelId, agent, used, size}` | `usage_update` |
| `terminal:data` | `{terminalId, data}` | PTY output (issue #31) |
| `workspace:changed` | `{agent, path}` | a file changed in a watched workspace |

The frontend renders Markdown at most once per animation frame (ADR 0002), so `message:delta` can be emitted for every ACP chunk without batching on the Go side; batching is added only if measurements on macOS require it (issue #13).

## Flow of a user message

```text
UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ internal/app
  ─▶ channels.Route: mentions → [garfield], command → "/idioms Il pleut"
  ─▶ agents.Session(channel, garfield): start mm if needed (limit!), session/new if needed
  ─▶ prompt = unseen channel messages + "/idioms Il pleut"
  ─▶ acp.Prompt … session/update → message:delta events … stopReason
  ─▶ store: Message saved, lastSeenMessageId updated ─▶ message:finished
```

## Amendment 1 — what M1 changed (2026-10-29)

- **The main package is at the repository root, not in `cmd/sidekicks`.** The Taskfiles Wails generates build `.` and generate the bindings from it on every platform; moving `main` would mean editing about ten generated commands that `wails3 update build-assets` overwrites. The root package stays a thin `main.go`.
- **Only `main.go` imports Wails.** `internal/app` exposes plain Go services and emits through `internal/events.Emitter`; `main.go` binds the services and plugs the Wails event manager in. `internal/app` is therefore tested without a window or cgo.
- **`internal/events`** is a new package: the event names and payloads below, the `Emitter` interface, and a `Recorder` for tests.
- **When the limit is reached**, starting an agent first stops the least recently used *idle* agent; only when every running agent is busy is the start refused.
- **Events of M1**: `config:changed`, `agent:state`, `agent:log`. The others of the catalogue arrive with their features.

## Amendment 2 — what M2 changed (2026-10-29)

Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target.

## Amendment 3 — what M3 changed (2026-10-29)

`internal/channels` (as planned in the package table) replaces `internal/conversations`: a direct conversation is a channel with one member. `internal/store` holds channels, members, messages and a small key-value space (`value/<key>`: the commands each agent announced); a direct conversation is indexed by `direct/<agent>` instead of a name. The database lives under `<user config dir>/sidekicks/data/<hash of the configuration path>/`. Events keep the M2 names, keyed by channel, plus `channels:changed` (the whole list) and `commands:changed`; `message:started` of the catalogue above is `message:updated`, as in M2. `ChannelService` replaces `ChatService`. `channels` uses `agents` through the `AgentSource` interface (Acquire, Definition), never the opposite. Details and reasons: [ADR 0006](0006-channels.md).

## Amendment 4 — what M4 changed (2026-10-29)

`internal/workspace` (as planned in the package table) reaches an agent's folder: `Local` (os, fsnotify, creack/pty) and `Container` (`docker exec`); `sbx` is refused with `ErrNotSupported` until its own issue. It also holds `Terminals`, the open shells and their output events. `internal/app` adds `WorkspaceService` and `TerminalService`; events `workspace:changed`, `terminal:output`, `terminal:exit`. `workspace` depends on `config` (the runtime) and `events`, nothing else. Details and reasons: [ADR 0007](0007-agent-workspaces.md).

## Consequences

- M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1).
- The diagram and this table are part of the definition of done of any change that adds or re-wires a package.
- Pebble v2 (`github.com/cockroachdb/pebble/v2`) is the store; its use is confined to `internal/store`.