bots-garden/sidekickspublic Fork
⑂ v1.2.0
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. 6713ff0 · on v1.2.0 · k33g · 3d ago
0004-conversations.md · 78 lines · 6.2 KBmarkdown
Blame HistoryOpen raw

ADR 0004 — Direct conversations, permissions and settings (milestone M2)

Context

Milestone M2 is "talk with one agent". Channels, multi-agent routing and persistence come with M3 (ADR 0003). Something must hold the conversations meanwhile, without closing the door to channels.

Decisions

1. One direct conversation per agent, in memory

Clicking an agent opens its conversation in the centre. internal/conversations keeps one conversation per agent name, in memory only: quitting forgets them (decided with the owner; Pebble arrives with #20). In M3 a direct conversation becomes a channel with one member; the message model below is the one ADR 0003 defines for channels, keyed by agent instead of channel.

2. Sessions follow the agent process

A conversation opens an ACP session (session/new, cwd = the agent's workspace) the first time it needs one, and keeps it while the same process runs. When the process changed — stopped for idleness, made room for another agent, crashed, restarted — the next message opens a new session and a system message says the agent no longer remembers the beginning (mm cannot reload sessions, ADR 0001). Re-injecting the context is issue #25.

A conversation borrows the agent only for the length of a turn (agents.Manager.Acquire/release): between turns the agent is idle and may be stopped to save memory.

3. One turn at a time, a queue for the rest

A message sent while the agent answers is queued (queued: true) and sent when the turn ends. Stop (Cancel) sends session/cancel, answers pending permission requests "cancelled" as ACP requires, and drops the queue — those messages are marked not sent.

4. Messages are made of parts

Part Content Streaming
text Markdown fragments appended
thought the agent's reasoning, folded in the UI fragments appended
tool an acp.ToolCall (title, kind, status, input, output, diffs) the whole part is replaced on each update
plan plan entries replaced

A finished agent message carries stopReason (end_turn, cancelled, max_tokens…) or error. System messages carry a code (agent-restarted, agent-unavailable) that the interface turns into a sentence: Go produces no user-facing French.

5. Events

Event Payload When
message:updated {agent, message} a message is added or finished
message:delta {agent, messageId, index, kind, text?, part?} a fragment (text, appended) or a replaced part (part)
turn:changed {agent, busy, queued} an agent starts or stops answering
permission:requested a Permission the agent asks
permission:resolved {id, agent} answered or cancelled
usage:changed {agent, usage} usage_update

The interface applies message:updated and message:delta through one queue flushed once per frame, so a finished message never overtakes its last fragments. It follows every conversation from startup, so selecting an agent never reloads its conversation from Go (which would apply fragments twice).

6. Permissions in plain words, with a way out

The permission card (wireframe 4) says what the agent wants in French ("veut exécuter une commande sur votre ordinateur"), shows the command or the file, flags commands that may destroy data (rm, mv, dd, git reset, an overwriting >… in command position), lists the agent's options with our labels — allow once, always allow here, never propose here before reject once — and always offers "Stop the agent".

Testing with the real Riker added a case the spike had not shown: after "never", mm refuses the tool by itself, the small model keeps calling it, and every call fails. No permission card appears any more, so the conversation shows "Riker tourne en rond : N outils ont échoué d'affilée" with a Stop button once 3 tool calls in a row have failed.

7. Readable agent errors

When an agent answers a prompt with a JSON-RPC error, internal/acp returns an AgentError whose message is the agent's own explanation (data.error, e.g. "generation timed out (stalled)") instead of the JSON envelope.

8. Stopping an agent: stdin, then SIGTERM, then SIGKILL

acp.Conn.Close closes stdin (5 s), then sends SIGTERM to the agent's process group (3 s), then SIGKILL. M1 went straight from stdin to SIGKILL, which kills scripts/agents.sh before it can unload the model; SIGTERM lets its trap unload it. A model left loaded after quitting was observed once during M2 testing; the cause was not reproduced (closing during a generation unloads correctly), the escalation narrows the window.

9. Settings: the Go side validates, the editor only displays

The Settings panel edits the file with CodeMirror. Live validation calls ConfigService.Validate, which runs the very loader of #9 (schema, cross-entry rules, French or English messages with lines); completion reads the JSON Schema from ConfigService.Schema. ConfigService.Save refuses an invalid text and writes a valid one atomically; the file watcher then reloads it. Nothing about the format is duplicated in TypeScript.

10. Opening another file and remembering it

ConfigService.Choose shows the native file dialog (injected by main.go, Wails stays out of internal/), Open forgets the conversations, stops every agent, loads the file (problems included), watches it and records it in <user config dir>/sidekicks/preferences.json (internal/prefs: last opened, 8 recent). At startup the last opened file is used when neither --config nor SIDEKICKS_CONFIG is given.

Consequences

  • New packages: internal/conversations, internal/prefs; agents.Conn gains NewSession, Prompt, Cancel.
  • internal/app gains ChatService and the Settings methods of ConfigService; main.go registers six more typed events.
  • The conversation shape (parts, codes, events keyed by agent) is what channels will extend in M3.
 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
# ADR 0004 — Direct conversations, permissions and settings (milestone M2)

- **Status**: accepted
- **Date**: 2026-10-29
- **Issues**: #14 (sessions and streaming), #15 (conversation view), #16 (Markdown), #17 (tool calls and permissions), #18 (Settings editor), #19 (another configuration file)
- **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [ADR 0003 — architecture](0003-architecture.md)

## Context

Milestone M2 is "talk with one agent". Channels, multi-agent routing and persistence come with M3 (ADR 0003). Something must hold the conversations meanwhile, without closing the door to channels.

## Decisions

### 1. One direct conversation per agent, in memory

Clicking an agent opens its conversation in the centre. `internal/conversations` keeps one conversation per agent name, in memory only: quitting forgets them (decided with the owner; Pebble arrives with #20). In M3 a direct conversation becomes a channel with one member; the message model below is the one ADR 0003 defines for channels, keyed by agent instead of channel.

### 2. Sessions follow the agent process

A conversation opens an ACP session (`session/new`, cwd = the agent's workspace) the first time it needs one, and keeps it while the same process runs. When the process changed — stopped for idleness, made room for another agent, crashed, restarted — the next message opens a new session and a system message says the agent no longer remembers the beginning (mm cannot reload sessions, ADR 0001). Re-injecting the context is issue #25.

A conversation borrows the agent only for the length of a turn (`agents.Manager.Acquire`/release): between turns the agent is idle and may be stopped to save memory.

### 3. One turn at a time, a queue for the rest

A message sent while the agent answers is queued (`queued: true`) and sent when the turn ends. Stop (`Cancel`) sends `session/cancel`, answers pending permission requests "cancelled" as ACP requires, and drops the queue — those messages are marked not sent.

### 4. Messages are made of parts

| Part | Content | Streaming |
| --- | --- | --- |
| `text` | Markdown | fragments appended |
| `thought` | the agent's reasoning, folded in the UI | fragments appended |
| `tool` | an `acp.ToolCall` (title, kind, status, input, output, diffs) | the whole part is replaced on each update |
| `plan` | plan entries | replaced |

A finished agent message carries `stopReason` (`end_turn`, `cancelled`, `max_tokens`…) or `error`. System messages carry a `code` (`agent-restarted`, `agent-unavailable`) that the interface turns into a sentence: Go produces no user-facing French.

### 5. Events

| Event | Payload | When |
| --- | --- | --- |
| `message:updated` | `{agent, message}` | a message is added or finished |
| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | a fragment (`text`, appended) or a replaced part (`part`) |
| `turn:changed` | `{agent, busy, queued}` | an agent starts or stops answering |
| `permission:requested` | a `Permission` | the agent asks |
| `permission:resolved` | `{id, agent}` | answered or cancelled |
| `usage:changed` | `{agent, usage}` | `usage_update` |

The interface applies `message:updated` and `message:delta` through **one** queue flushed once per frame, so a finished message never overtakes its last fragments. It follows every conversation from startup, so selecting an agent never reloads its conversation from Go (which would apply fragments twice).

### 6. Permissions in plain words, with a way out

The permission card (wireframe 4) says what the agent wants in French ("veut exécuter une commande sur votre ordinateur"), shows the command or the file, flags commands that may destroy data (`rm`, `mv`, `dd`, `git reset`, an overwriting `>`… in command position), lists the agent's options with our labels — allow once, always allow here, **never propose here** before reject once — and always offers "Stop the agent".

Testing with the real Riker added a case the spike had not shown: after "never", mm refuses the tool by itself, the small model keeps calling it, and every call fails. No permission card appears any more, so the conversation shows "Riker tourne en rond : N outils ont échoué d'affilée" with a Stop button once 3 tool calls in a row have failed.

### 7. Readable agent errors

When an agent answers a prompt with a JSON-RPC error, `internal/acp` returns an `AgentError` whose message is the agent's own explanation (`data.error`, e.g. "generation timed out (stalled)") instead of the JSON envelope.

### 8. Stopping an agent: stdin, then SIGTERM, then SIGKILL

`acp.Conn.Close` closes stdin (5 s), then sends SIGTERM to the agent's process group (3 s), then SIGKILL. M1 went straight from stdin to SIGKILL, which kills `scripts/agents.sh` before it can unload the model; SIGTERM lets its trap unload it. A model left loaded after quitting was observed once during M2 testing; the cause was not reproduced (closing during a generation unloads correctly), the escalation narrows the window.

### 9. Settings: the Go side validates, the editor only displays

The Settings panel edits the file with CodeMirror. Live validation calls `ConfigService.Validate`, which runs the very loader of #9 (schema, cross-entry rules, French or English messages with lines); completion reads the JSON Schema from `ConfigService.Schema`. `ConfigService.Save` refuses an invalid text and writes a valid one atomically; the file watcher then reloads it. Nothing about the format is duplicated in TypeScript.

### 10. Opening another file and remembering it

`ConfigService.Choose` shows the native file dialog (injected by `main.go`, Wails stays out of `internal/`), `Open` forgets the conversations, stops every agent, loads the file (problems included), watches it and records it in `<user config dir>/sidekicks/preferences.json` (`internal/prefs`: last opened, 8 recent). At startup the last opened file is used when neither `--config` nor `SIDEKICKS_CONFIG` is given.

## Consequences

- New packages: `internal/conversations`, `internal/prefs`; `agents.Conn` gains `NewSession`, `Prompt`, `Cancel`.
- `internal/app` gains `ChatService` and the Settings methods of `ConfigService`; `main.go` registers six more typed events.
- The conversation shape (parts, codes, events keyed by agent) is what channels will extend in M3.