opencode-workspace-mailbox 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +347 -2
- package/dist/config.d.ts +32 -0
- package/dist/config.js +85 -0
- package/dist/config.js.map +1 -0
- package/dist/discovery.d.ts +41 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/hash.d.ts +6 -0
- package/dist/hash.js +11 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +537 -0
- package/dist/index.js.map +1 -0
- package/dist/mailbox.d.ts +35 -0
- package/dist/mailbox.js +80 -0
- package/dist/mailbox.js.map +1 -0
- package/dist/parser.d.ts +17 -0
- package/dist/parser.js +78 -0
- package/dist/parser.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/prompt.d.ts +3 -0
- package/dist/prompt.js +15 -0
- package/dist/prompt.js.map +1 -0
- package/dist/state.d.ts +27 -0
- package/dist/state.js +94 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +34 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/package.json +46 -4
- package/src/config.ts +133 -0
- package/src/discovery.ts +133 -0
- package/src/hash.ts +12 -0
- package/src/index.ts +589 -0
- package/src/mailbox.ts +105 -0
- package/src/parser.ts +103 -0
- package/src/paths.ts +38 -0
- package/src/prompt.ts +20 -0
- package/src/state.ts +119 -0
- package/src/store.ts +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Renzo S. (mr-wolf-rsh)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,348 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-workspace-mailbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A per-project handoff mailbox for [OpenCode](https://opencode.ai) sessions.
|
|
4
|
+
|
|
5
|
+
Every session gets it: a file-based inbox that other sessions write to, automatic delivery when the
|
|
6
|
+
session goes idle, and loop guards so an agent can never talk itself into a spin. One global config
|
|
7
|
+
serves a whole folder tree — **meta → account → repo** — because the tier is derived from the
|
|
8
|
+
directory a session opens in, not from what you remember to configure.
|
|
9
|
+
|
|
10
|
+
Built for OpenCode today; the storage layer is behind a `MailboxStore` interface, so the file
|
|
11
|
+
backend can be swapped for SQLite or Engram — see [Backlog](#backlog).
|
|
12
|
+
|
|
13
|
+
> Status: the code is complete (81 tests, `typecheck` and `build` green, repo at
|
|
14
|
+
> [mr-wolf-rsh/opencode-workspace-mailbox](https://github.com/mr-wolf-rsh/opencode-workspace-mailbox)).
|
|
15
|
+
> It is not yet published to npm — see [Install](#install).
|
|
16
|
+
|
|
17
|
+
## The metaphor
|
|
18
|
+
|
|
19
|
+
The mailbox is a **chain of command made of files**. Each tier owns a `handoffs/` folder holding its
|
|
20
|
+
own `workspace.md` plus one file per immediate child. Messages only ever move along that ladder:
|
|
21
|
+
escalate up, delegate down, or talk to a sibling.
|
|
22
|
+
|
|
23
|
+
| In an organisation | In opencode-workspace-mailbox |
|
|
24
|
+
|---|---|
|
|
25
|
+
| The chief of staff | **Meta** tier — `D:\WORKSHOP` reads `handoffs/workspace.md` |
|
|
26
|
+
| Account leads | **Account** tier — `mr-wolf-rsh` reads `mr-wolf-rsh/handoffs/workspace.md` |
|
|
27
|
+
| The team doing the work | **Repo** tier — `<account>/handoffs/<project>.md` |
|
|
28
|
+
| Memos, never rewrites | Append-only markdown; history is the audit trail |
|
|
29
|
+
| "Received" stamp | `**Status:**` flips `OPEN` → `DONE`, one line, nothing else touched |
|
|
30
|
+
| Handing over a task | `mailbox_send({ to: "absolute-scrapers", ... })` |
|
|
31
|
+
| Standing instructions | `wake: true` — an idle session picks up new mail within one poll |
|
|
32
|
+
| Opening a desk for the recipient | `spawnOnSend: true` — creates a session in the recipient's folder |
|
|
33
|
+
| A message that needs a decision | Only then does the agent reply in chat |
|
|
34
|
+
|
|
35
|
+
Tiers are **discovered from the folder tree**, not configured: a directory with `handoffs/` is a
|
|
36
|
+
tier root, and anything under it resolves to its nearest ancestor that has one.
|
|
37
|
+
|
|
38
|
+
## Why this exists
|
|
39
|
+
|
|
40
|
+
Multi-repo workspaces talk through asynchronous handoffs: one file per project, append-only,
|
|
41
|
+
addressed `from X → to Y`. The protocol works. The human relay does not.
|
|
42
|
+
|
|
43
|
+
A message sits `OPEN` until somebody opens the right session, reads it, acts on it, and flips it to
|
|
44
|
+
`DONE` — and "somebody remembers" is exactly the step that fails. Worse, once you have a parent
|
|
45
|
+
strategy session and several implementation repos, the relay has to survive closed sessions,
|
|
46
|
+
restarts, and a working day that never lines up.
|
|
47
|
+
|
|
48
|
+
So this plugin closes the loop **inside the session that receives the message**. No daemon, no
|
|
49
|
+
scheduler, no `opencode run` spawning a session per message: if a session is closed, the message
|
|
50
|
+
simply waits in its file. When the session is open, the message surfaces on its own.
|
|
51
|
+
|
|
52
|
+
The file format is the product, not an implementation detail: it is readable, diffable, reviewable
|
|
53
|
+
in a PR, greppable, and it survives every process that ever touched it.
|
|
54
|
+
|
|
55
|
+
## What it does
|
|
56
|
+
|
|
57
|
+
Delivered as tools the agent calls and two hooks the runtime calls:
|
|
58
|
+
|
|
59
|
+
- **`mailbox_read`** — list `OPEN` messages for a project (or any routed recipient).
|
|
60
|
+
- **`mailbox_send`** — append a message to the recipient's mailbox, optionally spawning a session
|
|
61
|
+
in the recipient's directory.
|
|
62
|
+
- **`mailbox_ack`** — flip one message's `**Status:**` line to `DONE`, byte-preserving everywhere
|
|
63
|
+
else.
|
|
64
|
+
|
|
65
|
+
On `session.idle` (and, with `wake: true`, on a timer) the plugin reads the session's own mailbox,
|
|
66
|
+
finds `OPEN` messages it has not handled yet, and prompts the session **once** to deal with them.
|
|
67
|
+
|
|
68
|
+
### Delivery is automatic when you opt in
|
|
69
|
+
|
|
70
|
+
`wake: true` makes an already-idle session notice new mail. Without it, the plugin only reacts to an
|
|
71
|
+
idle *transition* — which means a session that has been sitting idle all afternoon never learns
|
|
72
|
+
about the message your other session just wrote.
|
|
73
|
+
|
|
74
|
+
### Spawning opens the recipient's desk
|
|
75
|
+
|
|
76
|
+
`spawnOnSend: true` creates a session in the recipient's project directory when you send to it, so a
|
|
77
|
+
project with nothing open can still be reached. Guarded so it cannot ping-pong — see
|
|
78
|
+
[Spawn on send](#spawn-on-send).
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
1. Add the plugin to your `opencode.json`:
|
|
83
|
+
|
|
84
|
+
```jsonc
|
|
85
|
+
{
|
|
86
|
+
"$schema": "https://opencode.ai/config.json",
|
|
87
|
+
"plugin": [
|
|
88
|
+
["opencode-workspace-mailbox", {
|
|
89
|
+
"wake": true,
|
|
90
|
+
"spawnOnSend": true
|
|
91
|
+
}]
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both flags are off by default: `wake` costs tokens while nobody is watching, and spawning creates
|
|
97
|
+
sessions on its own. Turn them on deliberately.
|
|
98
|
+
|
|
99
|
+
Do **not** set `mailboxDir` unless you deliberately want one pinned mailbox: it disables tier
|
|
100
|
+
discovery, which is the whole point of a single global config.
|
|
101
|
+
|
|
102
|
+
2. If your config directory declares plugin dependencies (some setups keep a `package.json` next to
|
|
103
|
+
`opencode.json`), add the package there too:
|
|
104
|
+
|
|
105
|
+
```powershell
|
|
106
|
+
cd C:\Users\<you>\.config\opencode
|
|
107
|
+
npm install opencode-workspace-mailbox --save
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
3. Restart OpenCode.
|
|
111
|
+
|
|
112
|
+
Local path variant during development (no publish needed — `prepare` builds it):
|
|
113
|
+
|
|
114
|
+
```powershell
|
|
115
|
+
npm install file:D:\WORKSHOP\mr-wolf-rsh\tools\opencode-workspace-mailbox --save
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```jsonc
|
|
119
|
+
["D:\\WORKSHOP\\mr-wolf-rsh\\tools\\opencode-workspace-mailbox", { "wake": true }]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
["opencode-workspace-mailbox", {
|
|
126
|
+
// Master switch. false = the plugin does nothing at all.
|
|
127
|
+
"enabled": true,
|
|
128
|
+
|
|
129
|
+
// Pin ONE mailbox directory and disable tier discovery. Leave unset normally.
|
|
130
|
+
"mailboxDir": null,
|
|
131
|
+
|
|
132
|
+
// Override the derived identity ("workspace" for a tier root, else the folder name).
|
|
133
|
+
"project": null,
|
|
134
|
+
|
|
135
|
+
// Storage backend. "file" is the MVP; the store interface is pluggable.
|
|
136
|
+
"storage": "file",
|
|
137
|
+
|
|
138
|
+
// Auto-prompts in a row without progress, then one toast instead. 0..20.
|
|
139
|
+
"maxAutoRounds": 2,
|
|
140
|
+
|
|
141
|
+
// Minimum gap between auto-prompts (and between notices).
|
|
142
|
+
"cooldownMs": 30000,
|
|
143
|
+
|
|
144
|
+
// Poll the mailbox and wake an idle session on a new message.
|
|
145
|
+
"wake": false,
|
|
146
|
+
"pollMs": 10000,
|
|
147
|
+
|
|
148
|
+
// Create a session in the recipient's directory when sending.
|
|
149
|
+
"spawnOnSend": false,
|
|
150
|
+
"spawnCooldownMs": 60000,
|
|
151
|
+
"maxSpawnsPerTurn": 1,
|
|
152
|
+
|
|
153
|
+
// Explicit recipient -> directory map for spawning. Discovery covers most trees.
|
|
154
|
+
"projectDirs": {},
|
|
155
|
+
|
|
156
|
+
// Where dedupe/round/spawn state is persisted (must stay inside the project directory).
|
|
157
|
+
"stateFile": null
|
|
158
|
+
}]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Option | Default | Meaning |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `enabled` | `true` | Master switch. |
|
|
164
|
+
| `mailboxDir` | discovered | Pin a mailbox directory; disables discovery. |
|
|
165
|
+
| `project` | resolved | Explicit identity override. |
|
|
166
|
+
| `storage` | `file` | Backend selector (`file` implemented). |
|
|
167
|
+
| `maxAutoRounds` | `2` | Auto-prompts in a row without progress, `0..20`. |
|
|
168
|
+
| `cooldownMs` | `30000` | Gap between auto-prompts and notices. |
|
|
169
|
+
| `wake` | `false` | Poll and wake an idle session. |
|
|
170
|
+
| `pollMs` | `10000` | Wake poll interval, `100..600000`. |
|
|
171
|
+
| `spawnOnSend` | `false` | Create a session in the recipient's directory. |
|
|
172
|
+
| `spawnCooldownMs` | `60000` | Gap between spawns. |
|
|
173
|
+
| `maxSpawnsPerTurn` | `1` | Spawns per human turn, `0..10`. |
|
|
174
|
+
| `projectDirs` | `{}` | Explicit recipient → directory map. |
|
|
175
|
+
| `stateFile` | `<dir>/.atl/mailbox-state.json` | Persisted dedupe/round/spawn state. |
|
|
176
|
+
|
|
177
|
+
## Tiers and identity
|
|
178
|
+
|
|
179
|
+
| Tier | Folder | Reads at start | Holds |
|
|
180
|
+
|---|---|---|---|
|
|
181
|
+
| **Meta** | `D:\WORKSHOP` | `handoffs/workspace.md` | `workspace.md` + one file per account |
|
|
182
|
+
| **Account** | `D:\WORKSHOP\mr-wolf-rsh` | `mr-wolf-rsh/handoffs/workspace.md` | `workspace.md` + one file per repo |
|
|
183
|
+
| **Repo** | `.../mr-wolf-rsh/tools/opencode-workspace-mailbox` | `mr-wolf-rsh/handoffs/opencode-workspace-mailbox.md` | its own file, at the account level |
|
|
184
|
+
|
|
185
|
+
Discovery rules, for a session opened in `D`:
|
|
186
|
+
|
|
187
|
+
1. `project` is set in config → use it verbatim (and `mailboxDir` if set).
|
|
188
|
+
2. `D` has `handoffs/` → `D` is a tier root. Parent has one too → **account**, else **meta**.
|
|
189
|
+
Identity is `workspace`; its mailbox is `D/handoffs`.
|
|
190
|
+
3. Otherwise → walk up to the nearest ancestor with `handoffs/`; that is the **account**, `D` is a
|
|
191
|
+
**repo**. Identity is `D`'s folder name, kebab-cased, and the mailbox is the account's
|
|
192
|
+
`handoffs/` — so bucket layouts like `<account>/tools/<repo>` work without configuration.
|
|
193
|
+
|
|
194
|
+
For this workspace:
|
|
195
|
+
|
|
196
|
+
| Session opened at | Tier | Mailbox |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `D:\WORKSHOP` | meta | `D:\WORKSHOP\handoffs\workspace.md` |
|
|
199
|
+
| `D:\WORKSHOP\mr-wolf-rsh` | account | `D:\WORKSHOP\mr-wolf-rsh\handoffs\workspace.md` |
|
|
200
|
+
| `D:\WORKSHOP\mr-wolf-rsh\tools\opencode-workspace-mailbox` | repo | `D:\WORKSHOP\mr-wolf-rsh\handoffs\opencode-workspace-mailbox.md` |
|
|
201
|
+
| `D:\WORKSHOP\absolute-labs-studio\absolute-scrapers` | repo | `D:\WORKSHOP\absolute-labs-studio\handoffs\absolute-scrapers.md` |
|
|
202
|
+
|
|
203
|
+
### Routing
|
|
204
|
+
|
|
205
|
+
- **`to: workspace`** → this tier's own `workspace.md`.
|
|
206
|
+
- **`to: <account>`** → the root tier's `<account>.md` — escalate up, or lateral between accounts.
|
|
207
|
+
- **`to: <anything else>`** — a sibling or child file in this tier: lateral, or delegate down.
|
|
208
|
+
|
|
209
|
+
The inbox for a tier is created on first run; an account session also creates its file at the root.
|
|
210
|
+
Unknown recipients are created on first send. Reads never create files.
|
|
211
|
+
|
|
212
|
+
## Mailbox format
|
|
213
|
+
|
|
214
|
+
Files are `<mailboxTier>/handoffs/<project>.md`. Append-only: messages are never rewritten,
|
|
215
|
+
reordered, or "tidied". The **only** permitted edit to history is the `**Status:**` line.
|
|
216
|
+
|
|
217
|
+
```markdown
|
|
218
|
+
### YYYY-MM-DD HH:MM — from <source> → to <recipient>
|
|
219
|
+
**Status:** OPEN | DONE
|
|
220
|
+
**Topic:** one-line subject
|
|
221
|
+
Body — what needs attention, the question, exact paths/commits/commands.
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`mailbox_ack` replaces the status line's content with `DONE`, so an annotated
|
|
225
|
+
`**Status:** OPEN (confirm the wording)` becomes `**Status:** DONE`. Every other byte — spacing
|
|
226
|
+
elsewhere, line endings, bodies — is unchanged; the tests assert that on CRLF fixtures, and it was
|
|
227
|
+
verified by hand against a real 113 KB mailbox file.
|
|
228
|
+
|
|
229
|
+
## Storage
|
|
230
|
+
|
|
231
|
+
Mailboxes sit behind a `MailboxStore` interface:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
interface MailboxStore {
|
|
235
|
+
ensure(project: string): string
|
|
236
|
+
listOpen(project: string): MailboxMessage[]
|
|
237
|
+
read(project: string): MailboxMessage[]
|
|
238
|
+
append(project: string, input: AppendInput): SendResult
|
|
239
|
+
ack(project: string, target: { hash?: string; header?: string }): AckResult
|
|
240
|
+
query(project: string, filter?: MailboxFilter): MailboxMessage[]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`createFileStore(dir)` is the reference backend. The message format and file layout are the contract:
|
|
245
|
+
a SQLite backend (atomic append, indexed OPEN-by-project, WAL concurrency) or an Engram-backed store
|
|
246
|
+
only has to implement those six operations — the tools, wake path, and spawning never change.
|
|
247
|
+
|
|
248
|
+
## Loop guards
|
|
249
|
+
|
|
250
|
+
| Guard | Behaviour |
|
|
251
|
+
|---|---|
|
|
252
|
+
| Hash dedupe | One prompt per message, persisted across restarts. |
|
|
253
|
+
| Own writes | Messages this plugin appended never wake anybody. |
|
|
254
|
+
| Round cap | `maxAutoRounds` prompts in a row without progress, then a toast instead. |
|
|
255
|
+
| Progress reset | An `mailbox_ack`, or a drained inbox, restores the budget. |
|
|
256
|
+
| Cooldown | `cooldownMs` between prompts; one notice per distinct pending set. |
|
|
257
|
+
| Idempotent ack | Acking an already-`DONE` message is a no-op, not an error. |
|
|
258
|
+
| Spawn guards | Opt-in, one per message hash, cooldown, per-turn cap, never from a spawned session, never when a session already exists. |
|
|
259
|
+
|
|
260
|
+
## How delivery decides
|
|
261
|
+
|
|
262
|
+
1. **Trigger** — `session.idle`, or a `wake` poll tick for the most recently active non-busy session.
|
|
263
|
+
2. **Skip** — plugin is disabled, this session was just prompted by the plugin, or the session is
|
|
264
|
+
mid-turn.
|
|
265
|
+
3. **Filter** — keep `OPEN` messages whose hash is not in `prompted` and not in `ownWrites`.
|
|
266
|
+
4. **Budget** — if the inbox is empty, reset the round budget; if the cap or cooldown is hit, stop
|
|
267
|
+
(surfacing a one-line toast at most once per pending set).
|
|
268
|
+
5. **Dispatch** — record every hash *before* prompting, then send one prompt: load the
|
|
269
|
+
`workspace-mailbox` skill, read the file, handle the messages, ack them, reply only if a decision
|
|
270
|
+
is needed.
|
|
271
|
+
|
|
272
|
+
Hashes are recorded before dispatch on purpose: a failed prompt is retried, never silently dropped.
|
|
273
|
+
|
|
274
|
+
## Spawn on send
|
|
275
|
+
|
|
276
|
+
With `spawnOnSend: true`, `mailbox_send` also creates a session in the recipient's directory
|
|
277
|
+
(`session.create` with `query.directory`), titled `mailbox: <recipient>`.
|
|
278
|
+
|
|
279
|
+
**Directory resolution**, in order:
|
|
280
|
+
|
|
281
|
+
1. `projectDirs[recipient]`, if configured.
|
|
282
|
+
2. `workspace` → this tier's own root directory.
|
|
283
|
+
3. An account name → that account's directory under the workspace root.
|
|
284
|
+
4. A sibling of the mailbox directory, or a bounded search for a nested bucket
|
|
285
|
+
(`<account>/tools/<repo>`).
|
|
286
|
+
|
|
287
|
+
If nothing resolves, the send still succeeds and the tool reports why no session was created. It
|
|
288
|
+
never guesses a directory.
|
|
289
|
+
|
|
290
|
+
**What spawning does not do:** it creates a server-side session; it does not attach a terminal
|
|
291
|
+
window. A headless session that hits a permission prompt has nobody to answer it.
|
|
292
|
+
|
|
293
|
+
## Limitations
|
|
294
|
+
|
|
295
|
+
- **Delivery needs a plugin instance serving the recipient directory.** With no session there, the
|
|
296
|
+
message waits in its file. `spawnOnSend` creates a session but cannot attach a TUI to it.
|
|
297
|
+
- **One project per plugin instance.** Cross-project activation beyond `session.create` is out of
|
|
298
|
+
scope; identity is resolved per session, never shared.
|
|
299
|
+
- **`wake` spends tokens with nobody watching**, at the pace messages arrive. Leave it off for
|
|
300
|
+
idle-only delivery.
|
|
301
|
+
- **No migration of old layouts.** Pointing the plugin at a mailbox directory that still holds
|
|
302
|
+
pre-hierarchy files leaves them in place; account files appear alongside.
|
|
303
|
+
- **Busy detection is event-driven.** If a runtime never emits `session.status`, the wake path can
|
|
304
|
+
queue a prompt behind an active turn instead of waiting.
|
|
305
|
+
- **Message bodies are data only.** They are never executed, evaluated, or turned into commands.
|
|
306
|
+
- **New recipient files are not registered in the tier's `README.md`.** The plugin writes mailboxes,
|
|
307
|
+
not documentation.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Backlog
|
|
312
|
+
|
|
313
|
+
Ideas for the future — not implemented yet.
|
|
314
|
+
|
|
315
|
+
- **SQLite backend** — implement `MailboxStore` over SQLite: atomic append, an index on
|
|
316
|
+
`(project, status)`, WAL for concurrent sessions, and `query` without re-parsing files. The
|
|
317
|
+
interface and the message format already allow it.
|
|
318
|
+
- **Engram backend** — reuse the persistent-memory store for teams that want mailboxes outside the
|
|
319
|
+
repo. Same six operations.
|
|
320
|
+
- **`to: all` broadcast** — the protocol allows a broadcast copied into every file in a tier; today
|
|
321
|
+
`all` is treated as a normal recipient name.
|
|
322
|
+
- **Migration helper** — move legacy top-level files into the tier layout with an explicit,
|
|
323
|
+
reviewable command instead of leaving them behind.
|
|
324
|
+
- **Watermark** — a `since` option so a fresh install does not replay a mailbox's entire `OPEN`
|
|
325
|
+
history on first contact.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Development
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
npm install
|
|
333
|
+
npm test # vitest: parser, ack byte-identity, send, paths, wake, spawn, tiers, store
|
|
334
|
+
npm run typecheck # tsc --noEmit
|
|
335
|
+
npm run build # tsc -> dist/
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Author
|
|
339
|
+
|
|
340
|
+
- **Renzo S.** — [mr-wolf-rsh](https://github.com/mr-wolf-rsh/)
|
|
341
|
+
|
|
342
|
+
## Contributing
|
|
343
|
+
|
|
344
|
+
All sorts of suggestions and pull requests are welcome.
|
|
345
|
+
|
|
346
|
+
## License
|
|
347
|
+
|
|
348
|
+
[MIT](./LICENSE)
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type MailboxConfig = {
|
|
2
|
+
mailboxDir?: string;
|
|
3
|
+
project?: string | null;
|
|
4
|
+
maxAutoRounds?: number;
|
|
5
|
+
cooldownMs?: number;
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
stateFile?: string | null;
|
|
8
|
+
wake?: boolean;
|
|
9
|
+
pollMs?: number;
|
|
10
|
+
spawnOnSend?: boolean;
|
|
11
|
+
spawnCooldownMs?: number;
|
|
12
|
+
maxSpawnsPerTurn?: number;
|
|
13
|
+
projectDirs?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
export type NormalizedConfig = {
|
|
16
|
+
mailboxDir: string;
|
|
17
|
+
project: string;
|
|
18
|
+
/** True when mailboxDir came from options (disables folder-tree discovery). */
|
|
19
|
+
explicitMailboxDir: boolean;
|
|
20
|
+
storage: "file";
|
|
21
|
+
maxAutoRounds: number;
|
|
22
|
+
cooldownMs: number;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
stateFile: string;
|
|
25
|
+
wake: boolean;
|
|
26
|
+
pollMs: number;
|
|
27
|
+
spawnOnSend: boolean;
|
|
28
|
+
spawnCooldownMs: number;
|
|
29
|
+
maxSpawnsPerTurn: number;
|
|
30
|
+
projectDirs: Record<string, string>;
|
|
31
|
+
};
|
|
32
|
+
export declare function normalizeConfig(options: Record<string, unknown> | undefined, directory: string): NormalizedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { assertInside, isSafeRecipient } from "./paths.js";
|
|
3
|
+
function clampInt(value, def, min, max) {
|
|
4
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
5
|
+
return def;
|
|
6
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
7
|
+
}
|
|
8
|
+
function nonNegativeInt(value, def) {
|
|
9
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
10
|
+
return def;
|
|
11
|
+
return Math.max(0, Math.floor(value));
|
|
12
|
+
}
|
|
13
|
+
/** Turn an arbitrary directory name into a lowercase kebab-case recipient id. */
|
|
14
|
+
function toKebab(value) {
|
|
15
|
+
return value
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-+|-+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
function isStrictlyInside(base, candidate) {
|
|
21
|
+
const rel = path.relative(path.resolve(base), path.resolve(candidate));
|
|
22
|
+
return rel !== "" && !path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + path.sep);
|
|
23
|
+
}
|
|
24
|
+
export function normalizeConfig(options, directory) {
|
|
25
|
+
const opts = options ?? {};
|
|
26
|
+
const configuredMailboxDir = typeof opts.mailboxDir === "string" && opts.mailboxDir.length > 0 ? opts.mailboxDir : null;
|
|
27
|
+
const mailboxDir = path.resolve(configuredMailboxDir ?? path.join(directory, "handoffs"));
|
|
28
|
+
// Only the file backend is implemented today; the field exists so a SQLite
|
|
29
|
+
// backend can be selected later without a config-format change.
|
|
30
|
+
const storage = "file";
|
|
31
|
+
const rawProject = opts.project;
|
|
32
|
+
const configuredProject = typeof rawProject === "string" && rawProject.length > 0 ? rawProject : null;
|
|
33
|
+
// Identity is resolved per session, not per config:
|
|
34
|
+
// explicit `project` wins; otherwise a session whose directory *contains*
|
|
35
|
+
// the shared mailbox directory is the workspace parent, and every other
|
|
36
|
+
// session uses its own directory name. The containment rule only applies to
|
|
37
|
+
// an explicitly configured mailboxDir, so the per-project default
|
|
38
|
+
// (`<directory>/handoffs`) never turns every project into the parent.
|
|
39
|
+
const project = configuredProject ??
|
|
40
|
+
(configuredMailboxDir !== null && isStrictlyInside(directory, mailboxDir)
|
|
41
|
+
? "workspace"
|
|
42
|
+
: toKebab(path.basename(path.resolve(directory))));
|
|
43
|
+
if (!isSafeRecipient(project)) {
|
|
44
|
+
throw new Error(configuredProject !== null
|
|
45
|
+
? `Invalid project name "${configuredProject}": must be lowercase kebab-case`
|
|
46
|
+
: `Cannot derive a valid project name from "${directory}": set the "project" plugin option`);
|
|
47
|
+
}
|
|
48
|
+
const maxAutoRounds = clampInt(opts.maxAutoRounds, 2, 0, 20);
|
|
49
|
+
const cooldownMs = nonNegativeInt(opts.cooldownMs, 30000);
|
|
50
|
+
const enabled = typeof opts.enabled === "boolean" ? opts.enabled : true;
|
|
51
|
+
const wake = typeof opts.wake === "boolean" ? opts.wake : false;
|
|
52
|
+
const pollMs = clampInt(opts.pollMs, 10000, 100, 600000);
|
|
53
|
+
const spawnOnSend = typeof opts.spawnOnSend === "boolean" ? opts.spawnOnSend : false;
|
|
54
|
+
const spawnCooldownMs = nonNegativeInt(opts.spawnCooldownMs, 60000);
|
|
55
|
+
const maxSpawnsPerTurn = clampInt(opts.maxSpawnsPerTurn, 1, 0, 10);
|
|
56
|
+
const projectDirs = {};
|
|
57
|
+
if (opts.projectDirs && typeof opts.projectDirs === "object" && !Array.isArray(opts.projectDirs)) {
|
|
58
|
+
for (const [key, value] of Object.entries(opts.projectDirs)) {
|
|
59
|
+
if (typeof value === "string" && value.length > 0 && isSafeRecipient(key)) {
|
|
60
|
+
projectDirs[key] = path.resolve(value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const rawStateFile = typeof opts.stateFile === "string" && opts.stateFile.length > 0
|
|
65
|
+
? opts.stateFile
|
|
66
|
+
: path.join(directory, ".atl", "mailbox-state.json");
|
|
67
|
+
const stateFile = assertInside(directory, rawStateFile, "stateFile");
|
|
68
|
+
return {
|
|
69
|
+
mailboxDir,
|
|
70
|
+
project,
|
|
71
|
+
explicitMailboxDir: configuredMailboxDir !== null,
|
|
72
|
+
storage,
|
|
73
|
+
maxAutoRounds,
|
|
74
|
+
cooldownMs,
|
|
75
|
+
enabled,
|
|
76
|
+
stateFile,
|
|
77
|
+
wake,
|
|
78
|
+
pollMs,
|
|
79
|
+
spawnOnSend,
|
|
80
|
+
spawnCooldownMs,
|
|
81
|
+
maxSpawnsPerTurn,
|
|
82
|
+
projectDirs,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type Tier = "meta" | "account" | "repo" | "custom";
|
|
2
|
+
export type TierContext = {
|
|
3
|
+
tier: Tier;
|
|
4
|
+
/** Mailbox identity inside its own tier: "workspace" for a tier root, else the directory name. */
|
|
5
|
+
project: string;
|
|
6
|
+
/** Directory holding this tier's files: `<tierRoot>/handoffs`. */
|
|
7
|
+
mailboxDir: string;
|
|
8
|
+
/** Session directory the context was derived from. */
|
|
9
|
+
directory: string;
|
|
10
|
+
/** The account directory (account or repo tier). */
|
|
11
|
+
accountRoot?: string;
|
|
12
|
+
accountName?: string;
|
|
13
|
+
/** The workspace root directory (meta tier, or the ancestor above an account). */
|
|
14
|
+
rootDir?: string;
|
|
15
|
+
/** The root tier's handoffs directory, when one exists above this tier. */
|
|
16
|
+
rootMailboxDir?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Derive the tier from the folder tree:
|
|
20
|
+
*
|
|
21
|
+
* - a directory with its own `handoffs/` is a tier root: meta when nothing
|
|
22
|
+
* above it has one, account when the parent does;
|
|
23
|
+
* - anything else belongs to the nearest ancestor that has one (repo tier),
|
|
24
|
+
* whatever the bucket depth (`<account>/tools/<repo>` still resolves);
|
|
25
|
+
* - an explicit `mailboxDir` in config overrides discovery entirely.
|
|
26
|
+
*/
|
|
27
|
+
export declare function discoverTier(directory: string, explicit?: {
|
|
28
|
+
mailboxDir?: string;
|
|
29
|
+
project?: string;
|
|
30
|
+
}): TierContext;
|
|
31
|
+
export type Route = {
|
|
32
|
+
mailboxDir: string;
|
|
33
|
+
project: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Where a message addressed to `to` belongs.
|
|
37
|
+
* - `workspace` -> this tier's own inbox (the tier root's `workspace.md`)
|
|
38
|
+
* - an account name -> the root tier's `<account>.md` (escalate up / lateral between accounts)
|
|
39
|
+
* - anything else -> a sibling/child file in this tier (lateral / delegate down)
|
|
40
|
+
*/
|
|
41
|
+
export declare function routeRecipient(to: string, ctx: TierContext): Route;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function kebab(value) {
|
|
4
|
+
return value
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
7
|
+
.replace(/^-+|-+$/g, "");
|
|
8
|
+
}
|
|
9
|
+
function hasHandoffs(dir) {
|
|
10
|
+
return existsSync(path.join(dir, "handoffs", "workspace.md")) || existsSync(path.join(dir, "handoffs"));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Derive the tier from the folder tree:
|
|
14
|
+
*
|
|
15
|
+
* - a directory with its own `handoffs/` is a tier root: meta when nothing
|
|
16
|
+
* above it has one, account when the parent does;
|
|
17
|
+
* - anything else belongs to the nearest ancestor that has one (repo tier),
|
|
18
|
+
* whatever the bucket depth (`<account>/tools/<repo>` still resolves);
|
|
19
|
+
* - an explicit `mailboxDir` in config overrides discovery entirely.
|
|
20
|
+
*/
|
|
21
|
+
export function discoverTier(directory, explicit) {
|
|
22
|
+
const dir = path.resolve(directory);
|
|
23
|
+
if (explicit?.mailboxDir) {
|
|
24
|
+
const mailboxDir = path.resolve(explicit.mailboxDir);
|
|
25
|
+
const inside = isStrictlyInside(dir, mailboxDir);
|
|
26
|
+
return {
|
|
27
|
+
tier: "custom",
|
|
28
|
+
project: explicit.project ?? (inside ? "workspace" : kebab(path.basename(dir))),
|
|
29
|
+
mailboxDir,
|
|
30
|
+
directory: dir,
|
|
31
|
+
rootDir: inside ? path.dirname(mailboxDir) : undefined,
|
|
32
|
+
rootMailboxDir: inside ? mailboxDir : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (hasHandoffs(dir)) {
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
const above = parent !== dir && hasHandoffs(parent);
|
|
38
|
+
return {
|
|
39
|
+
tier: above ? "account" : "meta",
|
|
40
|
+
project: "workspace",
|
|
41
|
+
mailboxDir: path.join(dir, "handoffs"),
|
|
42
|
+
directory: dir,
|
|
43
|
+
accountRoot: above ? dir : undefined,
|
|
44
|
+
accountName: above ? kebab(path.basename(dir)) : undefined,
|
|
45
|
+
rootDir: above ? parent : dir,
|
|
46
|
+
rootMailboxDir: path.join(above ? parent : dir, "handoffs"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let cursor = dir;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const parent = path.dirname(cursor);
|
|
52
|
+
if (parent === cursor)
|
|
53
|
+
break;
|
|
54
|
+
if (hasHandoffs(parent)) {
|
|
55
|
+
const grand = path.dirname(parent);
|
|
56
|
+
return {
|
|
57
|
+
tier: "repo",
|
|
58
|
+
project: kebab(path.basename(dir)),
|
|
59
|
+
mailboxDir: path.join(parent, "handoffs"),
|
|
60
|
+
directory: dir,
|
|
61
|
+
accountRoot: parent,
|
|
62
|
+
accountName: kebab(path.basename(parent)),
|
|
63
|
+
rootDir: grand,
|
|
64
|
+
rootMailboxDir: hasHandoffs(grand) ? path.join(grand, "handoffs") : undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
cursor = parent;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
tier: "repo",
|
|
71
|
+
project: kebab(path.basename(dir)),
|
|
72
|
+
mailboxDir: path.join(dir, "handoffs"),
|
|
73
|
+
directory: dir,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Where a message addressed to `to` belongs.
|
|
78
|
+
* - `workspace` -> this tier's own inbox (the tier root's `workspace.md`)
|
|
79
|
+
* - an account name -> the root tier's `<account>.md` (escalate up / lateral between accounts)
|
|
80
|
+
* - anything else -> a sibling/child file in this tier (lateral / delegate down)
|
|
81
|
+
*/
|
|
82
|
+
export function routeRecipient(to, ctx) {
|
|
83
|
+
if (to === "workspace")
|
|
84
|
+
return { mailboxDir: ctx.mailboxDir, project: "workspace" };
|
|
85
|
+
if (to === ctx.project)
|
|
86
|
+
return { mailboxDir: ctx.mailboxDir, project: ctx.project };
|
|
87
|
+
if (ctx.tier !== "meta" && ctx.rootMailboxDir) {
|
|
88
|
+
const ownAccount = ctx.accountName !== undefined && to === ctx.accountName;
|
|
89
|
+
const siblingAccount = existsSync(path.join(ctx.rootMailboxDir, `${to}.md`)) ||
|
|
90
|
+
(ctx.rootDir !== undefined && hasHandoffs(path.join(ctx.rootDir, to)));
|
|
91
|
+
if (ownAccount || siblingAccount) {
|
|
92
|
+
return { mailboxDir: ctx.rootMailboxDir, project: to };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { mailboxDir: ctx.mailboxDir, project: to };
|
|
96
|
+
}
|
|
97
|
+
function isStrictlyInside(base, candidate) {
|
|
98
|
+
const rel = path.relative(path.resolve(base), path.resolve(candidate));
|
|
99
|
+
return rel !== "" && !path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + path.sep);
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.js","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"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"}
|
package/dist/hash.d.ts
ADDED
package/dist/hash.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function normalizeForHash(text) {
|
|
3
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
4
|
+
}
|
|
5
|
+
export function messageHash(parts) {
|
|
6
|
+
const header = normalizeForHash(parts.header);
|
|
7
|
+
const topic = normalizeForHash(parts.topic);
|
|
8
|
+
const body = normalizeForHash(parts.body);
|
|
9
|
+
return createHash("sha256").update(`${header}\n${topic}\n${body}`).digest("hex").slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=hash.js.map
|