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/src/index.ts ADDED
@@ -0,0 +1,589 @@
1
+ import { tool, type Plugin } from "@opencode-ai/plugin"
2
+ import { existsSync, readdirSync, type Dirent } from "node:fs"
3
+ import path from "node:path"
4
+ import { normalizeConfig, type MailboxConfig, type NormalizedConfig } from "./config.js"
5
+ import { discoverTier, routeRecipient, type TierContext } from "./discovery.js"
6
+ import { messageHash, normalizeForHash } from "./hash.js"
7
+ import {
8
+ type AckRequest,
9
+ type AckResult,
10
+ type ReadResult,
11
+ type SendRequest,
12
+ type SendResult,
13
+ } from "./mailbox.js"
14
+ import { assertInside, isSafeRecipient, mailboxFile, PathSafetyError, resolveInside } from "./paths.js"
15
+ import { isOpen, parseMailbox, type MailboxMessage } from "./parser.js"
16
+ import { buildCapNotice, buildIdlePrompt } from "./prompt.js"
17
+ import { emptyState, StateStore, type MailboxState, type SessionState } from "./state.js"
18
+ import { createFileStore, type MailboxStore } from "./store.js"
19
+
20
+ /**
21
+ * opencode-workspace-mailbox plugin entry — makes a session aware of its per-project
22
+ * mailbox and auto-handles OPEN messages on idle, with loop guards.
23
+ *
24
+ * On the `session.idle` event we read the project's mailbox file, find OPEN
25
+ * messages we have not yet prompted for (and did not write ourselves), and —
26
+ * subject to a per-human-turn round cap and a cooldown — dispatch a single
27
+ * prompt asking the agent to handle them. The message is appended-only; the
28
+ * only permitted edit is flipping a `**Status:**` line to DONE via the
29
+ * `mailbox_ack` tool.
30
+ */
31
+ export const server: Plugin = async (input, options) => {
32
+ const cfg: NormalizedConfig = normalizeConfig(options, input.directory)
33
+ const store = new StateStore(cfg.stateFile)
34
+ const { client } = input
35
+
36
+ // Tier is derived from the folder tree unless the config pins a mailboxDir.
37
+ const tier: TierContext = cfg.explicitMailboxDir
38
+ ? discoverTier(input.directory, { mailboxDir: cfg.mailboxDir, project: cfg.project })
39
+ : discoverTier(input.directory)
40
+
41
+ // One store per mailbox directory; the backend is swappable behind MailboxStore.
42
+ const stores = new Map<string, MailboxStore>()
43
+ function storeFor(dir: string): MailboxStore {
44
+ const key = path.resolve(dir)
45
+ let found = stores.get(key)
46
+ if (!found) {
47
+ found = createFileStore(key)
48
+ stores.set(key, found)
49
+ }
50
+ return found
51
+ }
52
+ const own = (): MailboxStore => storeFor(tier.mailboxDir)
53
+
54
+ // Dynamic discovery creates the inboxes a tier is supposed to have.
55
+ try {
56
+ own().ensure(tier.project)
57
+ if (tier.tier === "account" && tier.rootMailboxDir && tier.accountName) {
58
+ storeFor(tier.rootMailboxDir).ensure(tier.accountName)
59
+ }
60
+ } catch (error) {
61
+ console.error(`[opencode-workspace-mailbox] inbox bootstrap failed: ${String(error)}`)
62
+ }
63
+
64
+ const MAX_ENTRIES = 500
65
+ /**
66
+ * How long a plugin-dispatched prompt stays "pending" while waiting for its
67
+ * own `chat.message` echo. A prompt that never arrives must not suppress the
68
+ * idle handler forever.
69
+ */
70
+ const PENDING_TTL_MS = 120_000
71
+
72
+ const pendingPluginPrompts = new Map<string, { count: number; at: number }>()
73
+
74
+ function pendingCount(sessionID: string): number {
75
+ const entry = pendingPluginPrompts.get(sessionID)
76
+ if (!entry) return 0
77
+ if (Date.now() - entry.at > PENDING_TTL_MS) {
78
+ pendingPluginPrompts.delete(sessionID)
79
+ return 0
80
+ }
81
+ return entry.count
82
+ }
83
+
84
+ function recordHashes(list: string[], hashes: string[]): void {
85
+ for (const hash of hashes) {
86
+ if (!list.includes(hash)) list.push(hash)
87
+ }
88
+ if (list.length > MAX_ENTRIES) list.splice(0, list.length - MAX_ENTRIES)
89
+ }
90
+
91
+ function dispatchPrompt(sessionID: string, text: string): void {
92
+ pendingPluginPrompts.set(sessionID, { count: pendingCount(sessionID) + 1, at: Date.now() })
93
+ const body = { parts: [{ type: "text" as const, text }] }
94
+ const promise = client.session.promptAsync
95
+ ? client.session.promptAsync({ path: { id: sessionID }, body })
96
+ : client.session.prompt({ path: { id: sessionID }, body })
97
+ promise.catch((error) => {
98
+ const remaining = pendingCount(sessionID)
99
+ if (remaining > 0) pendingPluginPrompts.set(sessionID, { count: remaining - 1, at: Date.now() })
100
+ console.error(`[opencode-workspace-mailbox] prompt dispatch failed: ${String(error)}`)
101
+ })
102
+ }
103
+
104
+ function notify(sessionID: string, message: string): void {
105
+ try {
106
+ const promise = client.tui.showToast({ body: { message, variant: "info" } })
107
+ promise.catch(() => {})
108
+ } catch {
109
+ // best-effort; never break the session over a toast
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Session registry: the wake path needs a session ID to prompt, and Events
115
+ // alone do not guarantee one for a session that never had a turn.
116
+ // ---------------------------------------------------------------------------
117
+ type SessionTrack = { lastSeen: number; busy: boolean; title?: string }
118
+ const sessions = new Map<string, SessionTrack>()
119
+
120
+ /** Prefix applied to every session this plugin creates, so other instances can spot it. */
121
+ const SPAWN_TITLE_PREFIX = "mailbox:"
122
+
123
+ function track(sessionID: string, busy?: boolean, title?: string): void {
124
+ if (!sessionID) return
125
+ const entry = sessions.get(sessionID) ?? { lastSeen: 0, busy: false }
126
+ entry.lastSeen = Date.now()
127
+ if (busy !== undefined) entry.busy = busy
128
+ if (title) entry.title = title
129
+ sessions.set(sessionID, entry)
130
+ }
131
+
132
+ function restoreBudget(sessionID: string): void {
133
+ const state = store.read()
134
+ const session = state.sessions[sessionID] ?? { autoRounds: 0, lastPromptAt: 0 }
135
+ session.autoRounds = 0
136
+ session.noticeKey = undefined
137
+ state.sessions[sessionID] = session
138
+ store.write(state)
139
+ }
140
+
141
+ /**
142
+ * Shared decision path for both triggers: the idle transition and the poll
143
+ * timer. `poll` additionally refuses to interrupt a busy session.
144
+ */
145
+ async function consider(sessionID: string, source: "idle" | "poll"): Promise<void> {
146
+ if (!cfg.enabled) return
147
+ if (pendingCount(sessionID) > 0) return
148
+ if (source === "poll" && sessions.get(sessionID)?.busy) return
149
+
150
+ const open = own().listOpen(tier.project)
151
+ const state = store.read()
152
+ const session = state.sessions[sessionID] ?? { autoRounds: 0, lastPromptAt: 0 }
153
+
154
+ if (open.length === 0) {
155
+ // Inbox drained — a later delivery starts with a full budget.
156
+ if (session.autoRounds !== 0 || session.noticeKey !== undefined) {
157
+ session.autoRounds = 0
158
+ session.noticeKey = undefined
159
+ state.sessions[sessionID] = session
160
+ store.write(state)
161
+ }
162
+ return
163
+ }
164
+
165
+ const fresh = open.filter(
166
+ (m) => !state.prompted.includes(m.hash) && !state.ownWrites.includes(m.hash),
167
+ )
168
+ if (fresh.length === 0) return
169
+
170
+ if (session.autoRounds >= cfg.maxAutoRounds) {
171
+ // One notice per distinct pending set: repeated triggers must not turn
172
+ // into a toast loop.
173
+ const noticeKey = fresh
174
+ .map((m) => m.hash)
175
+ .sort()
176
+ .join(",")
177
+ if (session.noticeKey === noticeKey) return
178
+ const now = Date.now()
179
+ if (session.noticeKey !== undefined && now - (session.lastNoticeAt ?? 0) < cfg.cooldownMs) return
180
+ session.noticeKey = noticeKey
181
+ session.lastNoticeAt = now
182
+ state.sessions[sessionID] = session
183
+ store.write(state)
184
+ notify(sessionID, buildCapNotice(tier.project, fresh.length, cfg.maxAutoRounds))
185
+ return
186
+ }
187
+
188
+ if (Date.now() - session.lastPromptAt < cfg.cooldownMs) return
189
+
190
+ recordHashes(state.prompted, fresh.map((m) => m.hash))
191
+ session.autoRounds += 1
192
+ session.lastPromptAt = Date.now()
193
+ session.noticeKey = undefined
194
+ state.sessions[sessionID] = session
195
+ store.write(state)
196
+ dispatchPrompt(sessionID, buildIdlePrompt(tier.project, fresh))
197
+ }
198
+
199
+ /** The most recently active session that is not mid-turn, if any. */
200
+ function wakeTarget(): string | undefined {
201
+ let best: { id: string; at: number } | undefined
202
+ for (const [id, entry] of sessions) {
203
+ if (entry.busy) continue
204
+ if (!best || entry.lastSeen > best.at) best = { id, at: entry.lastSeen }
205
+ }
206
+ return best?.id
207
+ }
208
+
209
+ async function poll(): Promise<void> {
210
+ if (!cfg.enabled) return
211
+ const sessionID = wakeTarget()
212
+ if (!sessionID) return
213
+ try {
214
+ await consider(sessionID, "poll")
215
+ } catch (error) {
216
+ console.error(`[opencode-workspace-mailbox] poll error: ${String(error)}`)
217
+ }
218
+ }
219
+
220
+ // Opt-in wake path: an idle transition only fires on a *change*, so a session
221
+ // that is already idle would never notice a message written by another
222
+ // project. The timer makes delivery independent of session transitions.
223
+ let timer: ReturnType<typeof setInterval> | undefined
224
+ if (cfg.wake) {
225
+ timer = setInterval(() => {
226
+ void poll()
227
+ }, cfg.pollMs)
228
+ timer.unref?.()
229
+ }
230
+
231
+ function sameDirectory(candidate: string | undefined): boolean {
232
+ if (!candidate) return false
233
+ const rel = path.relative(path.resolve(input.directory), path.resolve(candidate))
234
+ return rel === ""
235
+ }
236
+
237
+ /**
238
+ * A resumed session emits no creation event, so seed the registry from the
239
+ * server once at startup. `lastSeen` uses each session's own update time, so a
240
+ * stale session can never outrank one that is actually in use.
241
+ */
242
+ async function seedSessions(): Promise<void> {
243
+ if (!cfg.wake) return
244
+ try {
245
+ const result = await client.session.list({ query: { directory: input.directory } })
246
+ const list = (result as {
247
+ data?: Array<{ id?: string; directory?: string; title?: string; time?: { updated?: number } }>
248
+ }).data
249
+ for (const entry of list ?? []) {
250
+ if (!entry?.id || !sameDirectory(entry.directory)) continue
251
+ if (sessions.has(entry.id)) continue
252
+ sessions.set(entry.id, { lastSeen: entry.time?.updated ?? 0, busy: false, title: entry.title })
253
+ }
254
+ } catch {
255
+ // Session registry unavailable — chat.message and idle events still
256
+ // register sessions as they are used.
257
+ }
258
+ }
259
+
260
+ void seedSessions()
261
+
262
+ /**
263
+ * Recipient directory for spawn-on-send: an explicit `projectDirs` entry first,
264
+ * then the workspace root itself for `workspace`, then a sibling of `mailboxDir`.
265
+ * Anything unresolvable is a documented no-op, never a guess.
266
+ */
267
+ /** Bounded search for a child directory by basename (buckets like `tools/<repo>`). */
268
+ function findChildDir(root: string, name: string, depth = 3): string | undefined {
269
+ if (!existsSync(root)) return undefined
270
+ const target = name.toLowerCase()
271
+ const skip = new Set(["node_modules", "dist", "build", "bin", "obj", "target", "out"])
272
+ const queue: Array<{ dir: string; level: number }> = [{ dir: root, level: 0 }]
273
+ while (queue.length > 0) {
274
+ const current = queue.shift()!
275
+ if (current.level > 0 && path.basename(current.dir).toLowerCase() === target) return current.dir
276
+ if (current.level >= depth) continue
277
+ let entries: Dirent[]
278
+ try {
279
+ entries = readdirSync(current.dir, { withFileTypes: true })
280
+ } catch {
281
+ continue
282
+ }
283
+ for (const entry of entries) {
284
+ if (!entry.isDirectory()) continue
285
+ if (entry.name.startsWith(".") || skip.has(entry.name.toLowerCase())) continue
286
+ queue.push({ dir: path.join(current.dir, entry.name), level: current.level + 1 })
287
+ }
288
+ }
289
+ return undefined
290
+ }
291
+
292
+ function resolveProjectDir(to: string): string | undefined {
293
+ const mapped = cfg.projectDirs[to]
294
+ if (mapped) return mapped
295
+ const tierRoot = path.dirname(path.resolve(tier.mailboxDir))
296
+ if (to === "workspace") return existsSync(tierRoot) ? tierRoot : undefined
297
+ // Escalating to an account spawns that account's directory, not a sibling file.
298
+ if (tier.rootDir && to !== tier.project) {
299
+ const account = path.join(tier.rootDir, to)
300
+ if (existsSync(account)) return account
301
+ }
302
+ const sibling = path.join(tierRoot, to)
303
+ if (existsSync(sibling)) return sibling
304
+ // Bucket layouts: `<account>/tools/<repo>`, `<account>/labs/<repo>`, ...
305
+ return findChildDir(tierRoot, to)
306
+ }
307
+
308
+ async function findSession(directory: string): Promise<string | undefined> {
309
+ try {
310
+ const result = await client.session.list({ query: { directory } })
311
+ const list = (result as {
312
+ data?: Array<{ id?: string; directory?: string; time?: { updated?: number } }>
313
+ }).data ?? []
314
+ const wanted = path.resolve(directory)
315
+ const candidates = list.filter(
316
+ (entry) => entry.id && entry.directory && path.relative(wanted, path.resolve(entry.directory)) === "",
317
+ )
318
+ if (candidates.length === 0) return undefined
319
+ candidates.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
320
+ return candidates[0].id
321
+ } catch {
322
+ return undefined
323
+ }
324
+ }
325
+
326
+ type SpawnOutcome = { spawned: boolean; detail: string }
327
+
328
+ /**
329
+ * Create a session in the recipient's directory. Guards, in order: feature
330
+ * flag, self-send, cascade (a session this plugin spawned never spawns),
331
+ * duplicate message, cooldown, per-turn cap, then directory resolution.
332
+ */
333
+ async function maybeSpawn(sessionID: string, to: string, hash: string): Promise<SpawnOutcome> {
334
+ if (!cfg.spawnOnSend) return { spawned: false, detail: "spawnOnSend is off" }
335
+ if (to === tier.project) return { spawned: false, detail: "recipient is this project" }
336
+
337
+ const state = store.read()
338
+ const currentTitle = sessions.get(sessionID)?.title
339
+ if (currentTitle?.startsWith(SPAWN_TITLE_PREFIX) || state.spawnedSessions.includes(sessionID)) {
340
+ return { spawned: false, detail: "loop guard: this session was spawned" }
341
+ }
342
+ if (state.spawnedHashes.includes(hash)) {
343
+ return { spawned: false, detail: "already spawned for this message" }
344
+ }
345
+
346
+ const now = Date.now()
347
+ if (now - (state.lastSpawnAt ?? 0) < cfg.spawnCooldownMs) {
348
+ return { spawned: false, detail: `spawn cooldown (${cfg.spawnCooldownMs} ms)` }
349
+ }
350
+ const session = state.sessions[sessionID] ?? { autoRounds: 0, lastPromptAt: 0 }
351
+ if ((session.spawnsThisTurn ?? 0) >= cfg.maxSpawnsPerTurn) {
352
+ return { spawned: false, detail: `max ${cfg.maxSpawnsPerTurn} spawn(s) per turn reached` }
353
+ }
354
+
355
+ const directory = resolveProjectDir(to)
356
+ if (!directory) {
357
+ return { spawned: false, detail: `no directory known for "${to}" (set projectDirs)` }
358
+ }
359
+
360
+ const existing = await findSession(directory)
361
+ if (existing) return { spawned: false, detail: `session already exists (${existing})` }
362
+
363
+ try {
364
+ const created = await client.session.create({
365
+ query: { directory },
366
+ body: { title: `${SPAWN_TITLE_PREFIX} ${to}` },
367
+ })
368
+ const id = (created as { data?: { id?: string } }).data?.id
369
+ if (!id) return { spawned: false, detail: "session.create returned no id" }
370
+
371
+ session.spawnsThisTurn = (session.spawnsThisTurn ?? 0) + 1
372
+ state.sessions[sessionID] = session
373
+ state.lastSpawnAt = now
374
+ recordHashes(state.spawnedSessions, [id])
375
+ recordHashes(state.spawnedHashes, [hash])
376
+ store.write(state)
377
+ track(id, false, `${SPAWN_TITLE_PREFIX} ${to}`)
378
+ return { spawned: true, detail: `spawned session ${id} in ${directory}` }
379
+ } catch (error) {
380
+ return { spawned: false, detail: `session.create failed: ${String(error)}` }
381
+ }
382
+ }
383
+
384
+ return {
385
+ event: async ({ event }) => {
386
+ if (!cfg.enabled) return
387
+ try {
388
+ if (event.type === "session.idle") {
389
+ track(event.properties.sessionID, false)
390
+ await consider(event.properties.sessionID, "idle")
391
+ return
392
+ }
393
+ if (event.type === "session.status") {
394
+ track(event.properties.sessionID, event.properties.status.type !== "idle")
395
+ return
396
+ }
397
+ if (event.type === "session.created" || event.type === "session.updated") {
398
+ const info = (event.properties as { info?: { id?: string; directory?: string; title?: string } }).info
399
+ if (info?.id && sameDirectory(info.directory)) track(info.id, undefined, info.title)
400
+ }
401
+ } catch (error) {
402
+ console.error(`[opencode-workspace-mailbox] event handler error: ${String(error)}`)
403
+ }
404
+ },
405
+
406
+ dispose: async () => {
407
+ if (timer) {
408
+ clearInterval(timer)
409
+ timer = undefined
410
+ }
411
+ },
412
+
413
+ "chat.message": async ({ sessionID }) => {
414
+ try {
415
+ track(sessionID)
416
+ const count = pendingCount(sessionID)
417
+ if (count > 0) {
418
+ pendingPluginPrompts.set(sessionID, { count: count - 1, at: Date.now() })
419
+ return
420
+ }
421
+ const state = store.read()
422
+ state.sessions[sessionID] = { autoRounds: 0, lastPromptAt: 0 }
423
+ store.write(state)
424
+ } catch (error) {
425
+ console.error(`[opencode-workspace-mailbox] chat.message handler error: ${String(error)}`)
426
+ }
427
+ },
428
+
429
+ tool: {
430
+ mailbox_read: tool({
431
+ description:
432
+ "Read the OPEN mailbox messages for a project. Returns each message's header, hash, " +
433
+ "topic, and body so the agent can decide how to handle them. Pass a `project` to read a " +
434
+ "different recipient, or omit it to read the current project.",
435
+ args: {
436
+ project: tool.schema
437
+ .string()
438
+ .optional()
439
+ .describe("Project/recipient name (lowercase kebab-case). Defaults to the current project."),
440
+ },
441
+ async execute(args) {
442
+ const project = args.project ?? tier.project
443
+ if (!isSafeRecipient(project)) {
444
+ return { title: "mailbox_read", output: `Invalid project name "${project}": must be lowercase kebab-case.` }
445
+ }
446
+ const route = routeRecipient(project, tier)
447
+ const target = storeFor(route.mailboxDir)
448
+ const open = target.listOpen(route.project)
449
+ if (open.length === 0) {
450
+ return { title: "mailbox_read", output: `No OPEN messages for ${project}.`, metadata: { count: 0 } }
451
+ }
452
+ const file = mailboxFile(route.mailboxDir, route.project)
453
+ const lines = open.map((m) => `### ${m.header}\nHash: ${m.hash}\nTopic: ${m.topic}\n\n${m.body}`)
454
+ const output = `${project} (${file}): ${open.length} OPEN message(s)\n\n${lines.join("\n\n")}`
455
+ return { title: "mailbox_read", output, metadata: { count: open.length } }
456
+ },
457
+ }),
458
+
459
+ mailbox_send: tool({
460
+ description:
461
+ "Append a message to a recipient's mailbox file. Creates the recipient file if it does " +
462
+ "not exist. The message is appended (never rewrites history); by default it is OPEN.",
463
+ args: {
464
+ to: tool.schema
465
+ .string()
466
+ .describe("Recipient project name (lowercase kebab-case)."),
467
+ topic: tool.schema.string().describe("One-line subject for the message."),
468
+ body: tool.schema.string().describe("Message body. Data only; never executed."),
469
+ status: tool.schema
470
+ .enum(["OPEN", "DONE"])
471
+ .optional()
472
+ .describe("Initial status. Defaults to OPEN."),
473
+ },
474
+ async execute(args, ctx) {
475
+ try {
476
+ if (!isSafeRecipient(args.to)) {
477
+ return {
478
+ title: "mailbox_send",
479
+ output: `Invalid recipient "${args.to}": must be lowercase kebab-case.`,
480
+ }
481
+ }
482
+ const route = routeRecipient(args.to, tier)
483
+ const target = storeFor(route.mailboxDir)
484
+ const result = target.append(route.project, {
485
+ from: tier.project,
486
+ topic: args.topic,
487
+ body: args.body,
488
+ status: args.status,
489
+ })
490
+ store.update((state: MailboxState) => {
491
+ recordHashes(state.ownWrites, [result.hash])
492
+ })
493
+ const spawn = await maybeSpawn(ctx.sessionID, args.to, result.hash)
494
+ const suffix = cfg.spawnOnSend ? ` Spawn: ${spawn.detail}.` : ""
495
+ return {
496
+ title: "mailbox_send",
497
+ output: `Appended message to ${args.to} (hash ${result.hash}) in ${result.file}.${suffix}`,
498
+ metadata: { hash: result.hash, spawned: spawn.spawned },
499
+ }
500
+ } catch (error) {
501
+ return { title: "mailbox_send", output: `Failed to send message: ${String(error)}` }
502
+ }
503
+ },
504
+ }),
505
+
506
+ mailbox_ack: tool({
507
+ description:
508
+ "Flip a message's `**Status:**` line to DONE. Identify the message by exactly one of " +
509
+ "`hash` or `header`. Only that line is edited; every other byte is preserved.",
510
+ args: {
511
+ project: tool.schema
512
+ .string()
513
+ .optional()
514
+ .describe("Project/recipient name (lowercase kebab-case). Defaults to the current project."),
515
+ hash: tool.schema.string().optional().describe("Stable message hash to ack."),
516
+ header: tool.schema.string().optional().describe("Message header line to ack (case-insensitive)."),
517
+ },
518
+ async execute(args, ctx) {
519
+ const project = args.project ?? tier.project
520
+ if (!isSafeRecipient(project)) {
521
+ return { title: "mailbox_ack", output: `Invalid project name "${project}": must be lowercase kebab-case.` }
522
+ }
523
+ const route = routeRecipient(project, tier)
524
+ const result = storeFor(route.mailboxDir).ack(route.project, {
525
+ hash: args.hash,
526
+ header: args.header,
527
+ })
528
+ // An ack is progress: it restores the auto-handling budget so a
529
+ // backlog can drain one message per wake instead of stalling at the cap.
530
+ if (result.changed) restoreBudget(ctx.sessionID)
531
+ switch (result.reason) {
532
+ case "not-found":
533
+ return { title: "mailbox_ack", output: `No matching message found in ${project}.` }
534
+ case "already-done":
535
+ return { title: "mailbox_ack", output: `Message already DONE in ${project}.` }
536
+ case "ambiguous":
537
+ return { title: "mailbox_ack", output: `Ambiguous match in ${project}; provide a \`hash\`.` }
538
+ case "missing-args":
539
+ return { title: "mailbox_ack", output: "Provide exactly one of `hash` or `header`." }
540
+ default:
541
+ return {
542
+ title: "mailbox_ack",
543
+ output: `Marked message DONE in ${project}${result.message ? ` (${result.message.header})` : ""}.`,
544
+ }
545
+ }
546
+ },
547
+ }),
548
+ },
549
+ }
550
+ }
551
+
552
+ export {
553
+ normalizeConfig,
554
+ parseMailbox,
555
+ isOpen,
556
+ messageHash,
557
+ normalizeForHash,
558
+ isSafeRecipient,
559
+ resolveInside,
560
+ assertInside,
561
+ mailboxFile,
562
+ PathSafetyError,
563
+ emptyState,
564
+ StateStore,
565
+ buildIdlePrompt,
566
+ buildCapNotice,
567
+ discoverTier,
568
+ routeRecipient,
569
+ createFileStore,
570
+ }
571
+ export type {
572
+ NormalizedConfig,
573
+ MailboxConfig,
574
+ MailboxMessage,
575
+ SendRequest,
576
+ SendResult,
577
+ ReadResult,
578
+ AckRequest,
579
+ AckResult,
580
+ SessionState,
581
+ MailboxState,
582
+ TierContext,
583
+ MailboxStore,
584
+ }
585
+
586
+ export default {
587
+ id: "opencode-workspace-mailbox",
588
+ server,
589
+ }
package/src/mailbox.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { messageHash } from "./hash.js"
4
+ import { isOpen, parseMailbox, type MailboxMessage } from "./parser.js"
5
+ import { isSafeRecipient, mailboxFile, PathSafetyError } from "./paths.js"
6
+
7
+ export type SendRequest = {
8
+ mailboxDir: string
9
+ to: string
10
+ from: string
11
+ topic: string
12
+ body: string
13
+ status?: "OPEN" | "DONE"
14
+ now?: Date
15
+ }
16
+
17
+ export type SendResult = { file: string; header: string; hash: string; text: string }
18
+
19
+ function pad2(n: number): string {
20
+ return String(n).padStart(2, "0")
21
+ }
22
+
23
+ function buildHeader(to: string, from: string, now: Date): string {
24
+ const date = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`
25
+ const time = `${pad2(now.getHours())}:${pad2(now.getMinutes())}`
26
+ return `${date} ${time} — from ${from} → to ${to}`
27
+ }
28
+
29
+ export function sendMessage(req: SendRequest): SendResult {
30
+ if (!isSafeRecipient(req.to)) {
31
+ throw new PathSafetyError(`Invalid recipient "${req.to}"`)
32
+ }
33
+ const topic = (req.topic ?? "").replace(/\r?\n/g, " ").trim()
34
+ if (!topic) throw new Error("topic is required")
35
+ const body = (req.body ?? "").trim()
36
+ const file = mailboxFile(req.mailboxDir, req.to)
37
+ mkdirSync(path.dirname(file), { recursive: true })
38
+
39
+ const status = req.status ?? "OPEN"
40
+ const header = buildHeader(req.to, req.from, req.now ?? new Date())
41
+ const block = `### ${header}\n**Status:** ${status}\n**Topic:** ${topic}\n${body}\n`
42
+ const preamble = `# ${req.to} mailbox\n\nMessages addressed to \`${req.to}\`. Append-only.\n`
43
+
44
+ if (!existsSync(file)) {
45
+ writeFileSync(file, preamble, "utf8")
46
+ appendFileSync(file, `\n${block}`, "utf8")
47
+ } else {
48
+ const existing = readFileSync(file, "utf8")
49
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n"
50
+ appendFileSync(file, `${sep}${block}`, "utf8")
51
+ }
52
+
53
+ const hash = messageHash({ header, topic, body })
54
+ return { file, header, hash, text: block }
55
+ }
56
+
57
+ export type ReadResult = { file: string; exists: boolean; messages: MailboxMessage[]; open: MailboxMessage[] }
58
+
59
+ export function readMailbox(file: string): ReadResult {
60
+ if (!existsSync(file)) return { file, exists: false, messages: [], open: [] }
61
+ const text = readFileSync(file, "utf8")
62
+ const messages = parseMailbox(text)
63
+ return { file, exists: true, messages, open: messages.filter(isOpen) }
64
+ }
65
+
66
+ export type AckRequest = { file: string; hash?: string; header?: string }
67
+ export type AckResult = {
68
+ changed: boolean
69
+ message?: MailboxMessage
70
+ reason?: "not-found" | "already-done" | "ambiguous" | "missing-args"
71
+ }
72
+
73
+ export function ackMessage(req: AckRequest): AckResult {
74
+ const hasHash = typeof req.hash === "string" && req.hash.length > 0
75
+ const hasHeader = typeof req.header === "string" && req.header.length > 0
76
+ if ((hasHash ? 1 : 0) + (hasHeader ? 1 : 0) !== 1) {
77
+ return { changed: false, reason: "missing-args" }
78
+ }
79
+ if (!existsSync(req.file)) return { changed: false, reason: "not-found" }
80
+
81
+ const text = readFileSync(req.file, "utf8")
82
+ const messages = parseMailbox(text)
83
+
84
+ const matches = messages.filter((m) => {
85
+ if (hasHash) return m.hash === req.hash
86
+ return m.header.trim().toLowerCase() === req.header!.trim().toLowerCase()
87
+ })
88
+
89
+ if (matches.length === 0) return { changed: false, reason: "not-found" }
90
+ if (matches.length > 1) return { changed: false, reason: "ambiguous" }
91
+
92
+ const message = matches[0]
93
+ if (message.status === "DONE") return { changed: false, message, reason: "already-done" }
94
+ if (message.statusStart < 0 || message.statusEnd < 0) return { changed: false, reason: "not-found" }
95
+
96
+ // Replace the whole status-line content, not just the first word: real
97
+ // messages carry annotations like `OPEN (confirm one detail)` and flipping the
98
+ // token alone would leave a nonsensical `DONE (confirm one detail)`.
99
+ const lineEnd = message.statusLineEnd >= message.statusEnd ? message.statusLineEnd : message.statusEnd
100
+ const next = text.slice(0, message.statusStart) + "DONE" + text.slice(lineEnd)
101
+ writeFileSync(req.file, next, "utf8")
102
+
103
+ const updated = parseMailbox(next).find((m) => m.hash === message.hash) ?? message
104
+ return { changed: true, message: updated }
105
+ }