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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
|
# Reference: agents, channels and events
## Agent states
| State | Shown as | Meaning |
| --- | --- | --- |
| `stopped` | stopped | No process. The initial state of every agent. |
| `starting` | starting… | The process is launched and the ACP handshake (`initialize`) is in progress. |
| `ready` | ready | Running and idle. |
| `busy` | busy | Running and used by at least one conversation. |
| `error` | error | The start failed, or the process exited on its own (crash). The status carries the reason, followed by the agent's last stderr lines for a failed start. |
`outdated` (shown as "restart to apply changes") is set on a running agent whose definition changed in the configuration; a restart applies it.
## Transitions
| From | Event | To |
| --- | --- | --- |
| `stopped`, `error` | start requested (▶ in the Agents panel, or first use) | `starting` |
| `starting` | handshake succeeds | `ready` |
| `starting` | handshake fails, protocol other than ACP v1, timeout (30 s) | `error` |
| `ready` | used | `busy` |
| `busy` | last user done | `ready` |
| `ready`, `busy` | stop requested (■), application quits, agent removed from the configuration | `stopped` |
| `ready` | unused for `limits.stopIdleAgentsAfter` (checked every 30 s) | `stopped` |
| `ready` | another agent must start and this one is the least recently used idle agent | `stopped` |
| `ready`, `busy` | the process exits on its own | `error` (never restarted automatically) |
| `starting` | stop requested | `stopped` once the start ends |
When `limits.maxRunningAgents` agents are running and all are `busy`, a start is refused with the message `too many agents running: N agent(s) busy (…), the limit is N`.
Stopping an agent closes its stdin, waits 5 seconds, then kills its process group (the agent and what it started, such as `mm` under `scripts/agents.sh`).
## Events from Go to the interface
| Event | Payload | Sent when |
| --- | --- | --- |
| `config:changed` | `{path, valid, problems[]}` | The configuration file was saved and reloaded (valid or not). |
| `agent:state` | `{agent, state, error?}` | An agent changes state or its definition changes. |
| `agent:log` | `{agent, line}` | An agent writes a line on its standard error, or a sandbox command of the agent prints one (prefixed with `sandbox: `). |
| `files:dropped` | `{target, files[]}` | Files are dropped from the system on an element marked `data-file-drop-target`; `target` is its `id`, `files` their absolute paths. |
The Go names and types are in `internal/events`; the TypeScript constants and types are generated in `frontend/bindings/events` and `frontend/bindings/…/internal/events`.
## Services called by the interface
| Service | Method | Effect |
| --- | --- | --- |
| `AgentService` | `List()` | Every agent with `name, title, icon, description, state, error, outdated, runtime, canStopSandbox, canRemoveSandbox`, in configuration order. |
| | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. A start first recreates a missing sandbox when `runtime.lifecycle.create` is set. |
| | `StopSandbox(name)`, `RemoveSandbox(name)` | Stops the agent, then runs `runtime.lifecycle.stop` or `remove` (container or sandbox); the output goes to the agent's log. |
| | `Logs(name)` | The last 200 stderr lines of the agent. |
| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, historyProblem`. |
| `SystemService` | `Memory()` | `level` (`normal`, `warning`, `critical`), `used` and `total` in bytes; all empty (`total` 0) where the memory cannot be measured. macOS: the level of `kern.memorystatus_vm_pressure_level`, used = app + wired + compressed memory (`vm_stat`), as in Activity Monitor. Linux and Windows: worked out from the available memory, `warning` under 20 %, `critical` under 10 %. The Log panel calls it every 5 seconds while it is open (issue #61). |
## Channels (milestones M2 and M3)
A channel holds a conversation between the user and one or several agents. A direct conversation is a channel with one member, no name, and `direct` set to its agent's name. Channels, members and finished messages are kept in the history of the configuration file (below); a streaming answer is kept once it ends.
### Channels
| Field | Meaning |
| --- | --- |
| `id` | sortable identifier |
| `name`, `description` | 1 to 64 characters, unique whatever the case; empty for a direct conversation |
| `direct` | the agent of a direct conversation; empty for a channel |
| `members` | agent names, in the order they joined |
### Who answers a message
| The message | Answered by |
| --- | --- |
| `/agent:command arguments` (`agent` is a configured agent) | that agent, which receives `/command arguments` |
| contains `@name` mentions of configured agents | the mentioned agents, one after the other, in the order of the mentions; a mentioned agent that is not a member joins first |
| no mention | the last agent who answered in the channel; with a single member, that member |
| no mention and nobody answered yet, several members | nobody: system message `no-recipient` |
| any message in a direct conversation | its agent; mentions are plain text there |
A message starting with `/` (possibly after mentions) is a command: it is sent alone. Any other message is sent with the channel messages the agent has not seen yet (at most the last 20, each cut at 1500 characters). An agent answers one channel at a time; a channel waiting for it shows whose turn it is.
### Joined files
A `$path` at the start of a word joins the file at `path` of an agent's folder (issue #53): in a direct conversation, its agent's; in a channel, the first agent mentioned with `@`, otherwise the first member's. A `$` inside code, after another character (`5$`), escaped (`\$`) or followed by no letter (`$5`) is text; punctuation ending a sentence is not part of the path. A path ends at the first space.
Each agent that answers receives the files with the message (not with a slash command), as ACP content blocks chosen from what it announced:
| File | `promptCapabilities` | Sent as |
| --- | --- | --- |
| image (`image/*`) | `image` | `image` block (base64), with its URI |
| text, 1 MB at most | `embeddedContext` | `resource` block with the text |
| any other text or image | — | `resource_link`: URI `file://` + the path as the agent sees it, name, type, size |
| neither text nor image | — | not sent: `attachment-unavailable` |
A file is read when the agent's turn comes (5 MB at most); one that cannot be read gives `attachment-unavailable`, and the agent answers without it.
### Messages
| Field | Meaning |
| --- | --- |
| `id`, `channel`, `createdAt` | identity |
| `agent` | the author of an agent message, or the agent a system message is about |
| `role` | `user`, `agent` or `system` |
| `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) |
| `done` | false while an agent message streams |
| `queued` | a user message waiting for the current answers |
| `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
| `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop |
| `code` | system messages: `agent-joined`, `agent-left`, `no-recipient`, `agent-unavailable` (could not start or reach it; `error` says why), `attachment-unavailable` (a joined file was not sent to `agent`; `error` names it and says why) |
| `attachments` | user messages: the files joined with `$path`, as `{agent, path}` (the agent whose folder holds it). Only the reference is kept, never the content. |
A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`).
### Events
| Event | Payload | Sent when |
| --- | --- | --- |
| `channels:changed` | `{channels[]}` | a channel is created, renamed or deleted, or its members change |
| `message:updated` | `{channel, message}` | a message is added, or finished |
| `message:delta` | `{channel, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) |
| `turn:changed` | `{channel, busy, queued, answering}` | the channel starts or stops answering, or another agent's turn begins; `queued` messages wait |
| `permission:requested` | `{id, channel, agent, messageId, toolCall, options[], rejections}` | an agent asks to run a tool; `rejections` counts the refusals already given in this turn |
| `permission:resolved` | `{id, channel}` | the request was answered, or cancelled by Stop |
| `usage:changed` | `{channel, agent, usage: {used, size}}` | an agent reported its context window use (`size` is 0 when unknown) |
| `commands:changed` | `{agent, commands[]}` | an agent announced its slash commands (`name, description, hint`) |
### `ChannelService`
| Method | Effect |
| --- | --- |
| `List()` | Every channel and direct conversation, oldest first. |
| `Create(name, description)` | Creates a channel. A taken or empty name is an error. |
| `Rename(id, name, description)` | Renames a channel (not a direct conversation). |
| `Delete(id)` | Stops its answers, then deletes the channel and its whole history. |
| `Clear(id)` | Erases every message of a channel or direct conversation and keeps its name, description and members. The members' sessions are forgotten (`sessionId` and `lastSeen` emptied): their next turn opens a new session, which knows nothing of what was erased; the usage is reset. Refused while the channel answers or has queued messages (`an agent is answering in this channel…`). Sends `channels:changed`. |
| `AddMember(id, agent)`, `RemoveMember(id, agent)` | Invites or removes an agent; the channel shows `agent-joined` or `agent-left`. |
| `Direct(agent)` | The direct conversation with `agent`, created the first time. |
| `Send(id, text)` | Adds a user message and returns its id; the agents that answer are started if needed; the message is queued if the channel is answering. Empty text and unknown channels are errors. Each `$path` joins a file of an agent's folder (see [Joined files](#joined-files)); more than 10 is an error. |
| `Cancel(id)` | Stops the current answer (`session/cancel`), the agents still to answer and the queued messages; answers pending permission requests "cancelled". |
| `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. |
| `Conversation(id)` | `channel, messages` (the last 100), `more, busy, queued, answering, usage{agent: …}, permissions`. |
| `Older(id, beforeId)` | `{messages, more}`: the 100 messages before `beforeId`. |
| `Commands(agent)` | The commands the agent announced last, kept from one run to the next; empty if it never announced any. |
| `Prepare(id)` | Starts the members of the channel that never announced their commands and opens their session for it (no model is loaded); returns once the sessions are open, the commands arrive as `commands:changed`. A member answering elsewhere is skipped. The session serves the member's first answer, which is still told the channel's past. |
### History
| | |
| --- | --- |
| Location | `<user config dir>/sidekicks/data/<16 hex characters>/`: one Pebble database per configuration file (the hash of its absolute path). `<user config dir>` is `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` or `~/.config` on Linux. |
| Kept | channels, members (with each agent's ACP session id and the last message it saw), finished messages, the last commands of each agent |
| Not kept | pending permission requests, queued messages, an answer interrupted by quitting |
| Unavailable | if the database cannot be opened (another Sidekicks uses the same file), the history lives in memory until Sidekicks quits, and `ConfigService.Current().historyProblem` says why |
| After a restart | an agent that supports `session/load` resumes its session; otherwise its first prompt in a channel carries the last messages of that channel |
## Workspaces (milestone M4)
An agent's workspace is its `workspace` folder, reached according to `runtime.type`.
| Runtime | Files | Changes followed | Terminal | Deletion |
| --- | --- | --- | --- | --- |
| `local` | directly | file system notifications (not inside `.git`, `node_modules`), half a second after the last change | `$SHELL -l` (else bash, else sh) in a pseudo-terminal | to the system trash (`~/.Trash`, or the freedesktop.org trash); for good if the trash is on another disk |
| `container` | `docker exec` in `workspacePath` (default: the same path as `workspace`) | dates and sizes compared every 2 s | `docker exec -it`: bash if present, else sh | for good |
| `sbx` | `sbx exec` in `workspacePath` (default: the same path as `workspace`, where sbx mounts the host folder) | dates and sizes compared every 2 s | `sbx exec -it`: bash if present, else sh | for good |
Paths are relative to the folder and use `/`. A path leaving the folder (`..`, absolute, a symbolic link pointing out) is refused. The folder itself cannot be deleted.
### Files
| Field | Meaning |
| --- | --- |
| `name`, `path` | the entry's name, and its path from the folder |
| `dir` | a folder |
| `size`, `modTime` | size in bytes, last change |
A file opened for editing has `path, content, version`; `version` identifies the content.
### `WorkspaceService`
| Method | Effect |
| --- | --- |
| `Info(agent)` | `{location, problem}`: where the folder is (`local · /path`, `container name · /path`), or why it cannot be shown. |
| `List(agent, dir)` | The entries of `dir` (`""` is the folder), folders first, then by name. |
| `Read(agent, path)` | A text file of at most 1 MB; a binary or larger file is an error. |
| `Save(agent, path, content, version)` | Writes the file if it still has `version`; otherwise `{conflict: true}` and nothing is written. An empty `version` overwrites. The file is written next to its place and moved over it. |
| `Create(agent, dir, name)` | Creates an empty file; refused if the name exists or contains `/`. |
| `Remove(agent, path)` | Deletes a file or a folder with its content (see the table above). |
| `Attach(agent, hostPath)` | Joins a file of this computer to a message: returns its path in the agent's folder, to write after `$`. A file already in the folder is used where it is; any other is copied into `attachments/` (a free name, spaces turned into `-`, 5 MB at most). |
| `ChooseAttachment()` | Shows the native file dialog, from the home folder; returns the chosen path, or `""`. |
| `Image(agent, path)` | An image of the folder (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`…, 5 MB at most) as a `data:` URL, for the preview. Any other file is an error. |
| `Watch(agent)`, `Unwatch()` | Follows the changes of one agent's folder, announced by `workspace:changed`. |
| `Link(agent, href)` | `{path, revealable}`: the file of the agent's folder a link of its answer leads to — a relative path, an absolute path inside the folder (as the agent or this computer sees it), or a `file://` URL; the anchor and query are dropped, `%xx` decoded. The file need not exist yet. `revealable` when it is on this computer. Any other link, or a path leaving the folder, is an error (`this link is not a file of the agent's folder`). Issue #65. |
| `Reveal(agent, path)` | Shows the file in the file manager: `open -R` (macOS), `explorer /select,` (Windows), `xdg-open` of its folder (Linux). Only for a file of the folder on this computer. |
### `TerminalService`
| Method | Effect |
| --- | --- |
| `Open(agent, cols, rows)` | Starts a shell in the agent's folder; returns its id. |
| `Write(id, data)` | Sends keystrokes. |
| `Resize(id, cols, rows)` | Changes the size, in characters. |
| `Close(id)` | Hangs up the shell and everything it started (its descendants' process groups), background jobs included, then kills what is left after a second; in a container, inside the container too. |
### Events
| Event | Payload | Sent when |
| --- | --- | --- |
| `workspace:changed` | `{agent}` | files changed in the folder being watched |
| `terminal:output` | `{id, data}` | the terminal printed something; grouped every 16 ms or 32 KB, never cutting a character |
| `terminal:exit` | `{id}` | the shell exited |
## Settings methods of `ConfigService`
| Method | Effect |
| --- | --- |
| `Content()` | The text of the configuration file. |
| `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. |
| `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. |
| `Schema()` | The configuration JSON Schema. |
| `Open(path)` | Stops the answers and every agent, closes the history, loads and watches `path` with its own history, remembers it. |
| `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. |
| `Recent()` | The configuration files opened from the application, most recent first (8 at most). |
| `ReadSandboxEnv(path)` | Reads an sbx environment file for the assistant: `name, workspace, sandboxFolder, createCommand, stopCommand, removeCommand` (commands on one line). A file that is not one is an error. |
| `ChooseSandboxEnv()` | Shows the native file dialog, from the home folder, for an sbx environment file; returns its path, or `""` when cancelled. |
| `ChooseAgentConfig()` | The same, for an agent's configuration file (YAML). |
| `ReadCompose(path)` | Reads a Docker Compose file for the assistant: `folder`, and each service's `name, container, createCommand, stopCommand, removeCommand`. A file without services is an error. |
| `ChooseCompose()` | The native file dialog, for a Compose file. |
| `Reset(choice)` | The Danger zone: `settings` (default configuration, preferences forgotten), `history` (this configuration's), `allHistory` (every configuration's) or `everything`. Each thing reset is first kept as a dated copy; returns `{copies[]}`. The history is reopened empty and the declared channels created again; then `config:changed` is sent. |
## Stopping an agent
The agent's stdin is closed; after 5 s the agent and its children get SIGTERM (time for `scripts/agents.sh` to unload the model); after 3 more seconds, SIGKILL.
|