@brutalsystems/tincan-opencode 0.6.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Williams
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 ADDED
@@ -0,0 +1,136 @@
1
+ # Tin Can — opencode Plugin
2
+
3
+ ## What it does
4
+
5
+ This plugin makes live opencode sessions addressable as peers in Tin Can's messaging network. When installed, any active opencode session automatically advertises itself to the registry at `~/.tincan/peers/opencode/`, where Tin Can can discover and send messages to it. Without this plugin, there are no opencode peers — a session running in the TUI is invisible to peer messaging, even if Tin Can is installed.
6
+
7
+ The plugin receives inbound messages delivered to a Unix socket and injects them into the active session as prompts for the agent to act on.
8
+
9
+ ## Requirements
10
+
11
+ - **opencode** 1.18.31 (verified; other versions untested)
12
+ - **Tin Can** 0.4.0 or later
13
+
14
+ ## Install
15
+
16
+ The loader globs one level deep, so the installation is two copies:
17
+
18
+ ```bash
19
+ mkdir -p ~/.config/opencode/plugin
20
+ cp plugins/opencode/tincan.ts ~/.config/opencode/plugin/
21
+ cp -r plugins/opencode/tincan-lib ~/.config/opencode/plugin/
22
+ ```
23
+
24
+ ### Why two copies?
25
+
26
+ The opencode loader globs `{plugin,plugins}/*.{ts,js}` — one level deep only. `tincan.ts` must sit directly in `~/.config/opencode/plugin/`, or the plugin will not load at all.
27
+
28
+ The `tincan-lib/` directory contains unit-testable helpers and is never globbed by the loader, which is exactly what we want. It is namespace-separated (not called `lib/`) because `~/.config/opencode/plugin/` is shared with every other opencode plugin, and we need the name to be unambiguous.
29
+
30
+ Note: `~/.config/opencode/plugins/` (plural) is equally valid — both spellings work.
31
+
32
+ ## Verify the installation
33
+
34
+ 1. Start opencode normally:
35
+ ```bash
36
+ opencode
37
+ ```
38
+
39
+ 2. Within the TUI, send a message (type a message and press Enter — any input to the agent is fine).
40
+
41
+ 3. Check the registry in another terminal:
42
+ ```bash
43
+ ls -la ~/.tincan/peers/opencode/
44
+ ```
45
+
46
+ You should see:
47
+ - Session files named like `ses_*.json`
48
+ - An instance socket named like `inst-*.sock` (mode `0600`)
49
+
50
+ You will **not** see an `inst-*.caller.json` yet, and its absence is not a
51
+ failure. That file is written the first time the session invokes one of
52
+ Tin Can's MCP tools (`peers`, `send_peer`, `message_log`), which the steps
53
+ above do not do. It appears once you use one — and only if Tin Can is also
54
+ registered as an MCP server, which is a separate install.
55
+
56
+ 4. Examine a session file:
57
+ ```bash
58
+ cat ~/.tincan/peers/opencode/ses_*.json
59
+ ```
60
+
61
+ It should contain the session ID, slug, title, directory, state, and socket path.
62
+
63
+ ## Uninstall
64
+
65
+ Remove the plugin files:
66
+
67
+ ```bash
68
+ rm ~/.config/opencode/plugin/tincan.ts
69
+ rm -rf ~/.config/opencode/plugin/tincan-lib
70
+ ```
71
+
72
+ Clean up the registry (this step is optional but recommended):
73
+
74
+ ```bash
75
+ rm -rf ~/.tincan/peers/opencode
76
+ rm -f ~/.tincan/opencode-plugin.log ~/.tincan/opencode-plugin.log.1
77
+ ```
78
+
79
+ ## Troubleshooting
80
+
81
+ The plugin log is the only diagnostic surface. Output does not reach opencode's own log file; check this file instead:
82
+
83
+ ```bash
84
+ tail -f ~/.tincan/opencode-plugin.log
85
+ ```
86
+
87
+ Or with a custom `TINCAN_HOME`:
88
+
89
+ ```bash
90
+ tail -f "$TINCAN_HOME/opencode-plugin.log"
91
+ ```
92
+
93
+ ### Troubleshooting table
94
+
95
+ | Symptom | Cause | Action |
96
+ |---------|-------|--------|
97
+ | **No registry files appear at all** | The plugin is not loading, or initialization failed. | Check the plugin log for `event=selfcheck.failed`. This usually means opencode's private `client._client` field moved due to a version change. Only opencode 1.18.31 is verified. See SPEC.md §3. |
98
+ | **No file appears after `opencode --continue`** | Expected, not a bug. A resumed session is invisible until it next does something. | Send one message to the session (type input or wait for agent activity). The registry file will appear then. See SPEC.md §5. |
99
+ | **`event=bind.failed` mentioning socket path too long** | `TINCAN_HOME` directory nesting is too deep. macOS caps AF_UNIX socket paths near 103 bytes. | Shorten `TINCAN_HOME` or the path to it. For example, move `~/.tincan` to a shallower location. |
100
+ | **Messages accepted but nothing happens; `event=transport-broken detail=html response`** | The `/api/` prefix was lost in the request path. The opencode server falls back to its web UI and returns 200 with HTML instead of JSON, making an invalid request look like success. | Verify you are running opencode 1.18.31. Check the plugin source to ensure `POST /api/session/{sessionID}/prompt` is the exact path. |
101
+ | **`event=rejected status=409`** | The same `message_id` was re-sent with different content. opencode treats this as a mismatched re-submit. | This is not a retryable failure. Check the Tin Can side to ensure message IDs are not being duplicated. |
102
+ | **`event=dropped detail="missing envelope"`** | The `text` on the wire did not carry Tin Can's `<peer_message …>` envelope. | The envelope is the only thing marking an injected prompt as a peer's words rather than the operator's, so the plugin requires it. A hand-rolled sender must include it; from Tin Can itself this means a bug on the sending side. See SPEC.md §7. |
103
+ | **`event=dropped detail=unknown session`** | A message arrived for a session ID this plugin never heard announced. | Expected right after `opencode --continue` if messages arrive before the session is active. Send the message again; the session will be registered on its next activity. |
104
+ | **Stale peers listed in Tin Can, or socket files remain after a crash** | An instance was killed with `kill -9` or crashed without cleanup. Its registry files and socket remain. | Start a fresh opencode instance. On startup, the plugin sweeps orphaned sockets and removes any files for dead processes (identified by attempting a connection and seeing it refused). This is automatic. |
105
+
106
+ ## Known limits
107
+
108
+ 1. **Replay detection is per-process.** After an opencode restart, a re-sent `message_id` is logged as a fresh delivery even though opencode still de-duplicates it server-side. The plugin's log wording is approximate; the actual behaviour (no duplicate injection) is guaranteed.
109
+
110
+ 2. **The plugin log keeps one generation.** It rotates to `opencode-plugin.log.1` once it passes 4 MB, and the previous `.1` is overwritten. Nothing older is kept, so pipe it somewhere else if you need a longer history.
111
+
112
+ 3. **A resumed session is invisible until active.** When you run `opencode --continue`, the session does not appear in the registry until it next receives an event (a message, a user action, or agent activity). This was chosen over guessing from a session list, which would advertise closed sessions and silently lose messages. See SPEC.md §5 for details.
113
+
114
+ ## What it deliberately does not do
115
+
116
+ ### No outbound send path
117
+
118
+ This plugin only *receives* messages. For opencode to *send* messages to a peer, Tin Can must be registered as an MCP server in opencode's configuration and accessed as a tool. The plugin and the MCP server are separate installations and work in tandem.
119
+
120
+ **Both are needed for two-way messaging.** Installing only the plugin gives you a peer that can listen but not speak. Installing only Tin Can as an MCP server gives you a peer that can speak but not listen. No error is raised either way — you just get one-way silence.
121
+
122
+ ### No rate limiting
123
+
124
+ Rate limiting is Tin Can's responsibility, not the plugin's. The plugin injects every message it receives immediately. If a flood arrives, Tin Can is at fault.
125
+
126
+ The socket does cap concurrent connections (64) and drops one left idle for 30 seconds, but neither counts or delays messages: they bound the file descriptors and buffers a leaking sender can pin inside the opencode process, which is a different problem from too many messages.
127
+
128
+ ### No message logging
129
+
130
+ The plugin logs sender, session, delivery mode, and message ID for diagnostics, but it does not log message bodies. The user's work is recorded in Tin Can's own logs; duplicating it here would clutter the plugin log.
131
+
132
+ ### No `/tui/append-prompt` integration
133
+
134
+ Putting text in the human's compose box (the `/tui/append-prompt` endpoint) would interrupt a person rather than an agent. This is a different product decision that belongs in a later conversation about operator notification and workflow, not in the peer-messaging path.
135
+
136
+ See SPEC.md §11 for more details on out-of-scope features.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@brutalsystems/tincan-opencode",
3
+ "version": "0.6.0",
4
+ "description": "opencode plugin for Tin Can — the receive half, so an opencode session can be messaged by a live Claude Code, Codex or opencode peer.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "tincan",
9
+ "agents",
10
+ "agent-messaging"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Mike Williams",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/BrutalSystems/tincan.git",
17
+ "directory": "plugins/opencode"
18
+ },
19
+ "homepage": "https://github.com/BrutalSystems/tincan#readme",
20
+ "type": "module",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "exports": {
25
+ ".": "./tincan.ts"
26
+ },
27
+ "files": [
28
+ "tincan.ts",
29
+ "tincan-lib",
30
+ "README.md",
31
+ "LICENSE"
32
+ ]
33
+ }
@@ -0,0 +1,49 @@
1
+ import { join } from 'node:path';
2
+ import { isoStamp, writeJsonAtomic, type RecordContext } from './registry.js';
3
+
4
+ /**
5
+ * Which session is currently talking to Tin Can's MCP server.
6
+ *
7
+ * opencode exports no OPENCODE_SESSION_ID into tool subprocesses, and pid
8
+ * ancestry only identifies the instance. The `tool.execute.before` hook does
9
+ * carry the calling sessionID, so the plugin records it here for Tin Can to
10
+ * read when it needs to exclude itself from its own peer list.
11
+ */
12
+ export interface CallerRecord {
13
+ instance_id: string;
14
+ session_id: string;
15
+ pid: number;
16
+ tool: string;
17
+ at: string;
18
+ }
19
+
20
+ /** Distinct from a session record, which is always `ses_*.json`. */
21
+ export function callerFile(dir: string, instanceID: string): string {
22
+ return join(dir, `${instanceID}.caller.json`);
23
+ }
24
+
25
+ /**
26
+ * opencode names an MCP tool `<server key>_<tool name>`, and the server key is
27
+ * whatever the user put in their opencode config — `tincan`, `tc`, anything.
28
+ * So match the tool-name suffix, never a prefix. A leading `_` is required, so
29
+ * a built-in called `peers` does not match.
30
+ */
31
+ const TINCAN_TOOLS = ['peers', 'send_peer', 'message_log'];
32
+
33
+ export function isTincanTool(toolID: string): boolean {
34
+ return TINCAN_TOOLS.some((name) => toolID.endsWith(`_${name}`));
35
+ }
36
+
37
+ export function composeCaller(sessionID: string, toolID: string, ctx: RecordContext): CallerRecord {
38
+ return {
39
+ instance_id: ctx.instance_id,
40
+ session_id: sessionID,
41
+ pid: ctx.pid,
42
+ tool: toolID,
43
+ at: isoStamp(ctx.now()),
44
+ };
45
+ }
46
+
47
+ export async function writeCaller(dir: string, rec: CallerRecord): Promise<void> {
48
+ await writeJsonAtomic(callerFile(dir, rec.instance_id), rec);
49
+ }
@@ -0,0 +1,98 @@
1
+ import type { DeliveryOutcome, InboundMessage, Transport, TransportResponse } from './types.js';
2
+
3
+ /**
4
+ * The v1 route, deliberately, and deliberately NOT under /api.
5
+ *
6
+ * There are two prompt APIs and they are different engines. The /api one
7
+ * (`HttpApi.make("server")` in packages/protocol, self-described as an
8
+ * "Experimental HttpApi surface for selected instance routes") admits the
9
+ * message durably and asks a run coordinator to drain it. On a TUI-hosted
10
+ * session that drain fails to resolve the session's model and dies before the
11
+ * agent runs — 20 observed failures across two unrelated providers, zero
12
+ * successes — while telling nobody: the POST has already answered 200 and no
13
+ * error is written into the session.
14
+ *
15
+ * This one goes through SessionPrompt.Service instead and simply runs.
16
+ * Verified on stock opencode 1.18.31 in a TUI-hosted session: three sends,
17
+ * three turns, three answers. It is also what the reference integration
18
+ * (Intelligent-Internet/opencode-a2a) posts to.
19
+ *
20
+ * Do not "fix" this back to /api/. The old comment here warned that a missing
21
+ * prefix returns SPA HTML — true of the /api surface's own paths, and the
22
+ * reason `interpret` still guards for HTML, but this is a different route on
23
+ * a different API, not that one with a prefix dropped.
24
+ */
25
+ export function promptUrl(sessionID: string): string {
26
+ return `/session/${sessionID}/prompt_async`;
27
+ }
28
+
29
+ export interface TextPart {
30
+ type: 'text';
31
+ text: string;
32
+ }
33
+
34
+ /**
35
+ * PromptPayload is PromptInput minus sessionID (opencode
36
+ * packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:70).
37
+ * `parts` is the only required field; a text part needs only `type` and
38
+ * `text`. Everything else — model, agent, system, variant, noReply — is
39
+ * optional and deliberately left unset: Tin Can delivers a message, it does
40
+ * not reconfigure the peer's session.
41
+ *
42
+ * Note what is absent: `delivery`. v1 has no steer/queue, so `urgent` cannot
43
+ * be expressed on this leg and `runtimeSupportsUrgent` reports false for
44
+ * opencode accordingly. Sending a `delivery` key here would 400.
45
+ */
46
+ export interface PromptBody {
47
+ parts: TextPart[];
48
+ messageID: string;
49
+ }
50
+
51
+ export function promptBody(msg: InboundMessage): PromptBody {
52
+ return {
53
+ // Verbatim. The <peer_message> envelope is the only provenance marking on
54
+ // this path and must never be trimmed or reformatted. SPEC §7.
55
+ parts: [{ type: 'text', text: msg.text }],
56
+ // Our id, so message_log lines and opencode's own records agree.
57
+ messageID: msg.message_id,
58
+ };
59
+ }
60
+
61
+ export function interpret(res: TransportResponse, alreadySent: boolean): DeliveryOutcome {
62
+ const status = res.response?.status ?? 0;
63
+ const data: unknown = res.data;
64
+
65
+ if (typeof data === 'string' && data.trimStart().toLowerCase().startsWith('<!doctype')) {
66
+ return { kind: 'transport-broken', detail: 'html response — wrong route prefix?' };
67
+ }
68
+
69
+ if (status >= 400) {
70
+ const err = (typeof res.error === 'object' && res.error !== null ? res.error : {}) as Record<string, unknown>;
71
+ return {
72
+ kind: 'rejected',
73
+ status,
74
+ tag: typeof err._tag === 'string' ? err._tag : 'unknown',
75
+ detail: typeof err.message === 'string' ? err.message : '',
76
+ };
77
+ }
78
+
79
+ // prompt_async answers 204 with an empty body. Anything else in the 2xx
80
+ // range means we reached something that is not prompt_async — most likely
81
+ // the old /api route, which answered 200 — and reading that as success is
82
+ // how a half-applied upgrade would go unnoticed.
83
+ if (status === 204) return { kind: 'delivered', replay: alreadySent };
84
+ return { kind: 'transport-broken', detail: `expected 204 from prompt_async, got ${status}` };
85
+ }
86
+
87
+ export async function deliver(transport: Transport, msg: InboundMessage, alreadySent: Set<string>): Promise<DeliveryOutcome> {
88
+ const replay = alreadySent.has(msg.message_id);
89
+ let res: TransportResponse;
90
+ try {
91
+ res = await transport.post({ url: promptUrl(msg.to_session), body: promptBody(msg) });
92
+ } catch (e) {
93
+ return { kind: 'transport-broken', detail: String(e) };
94
+ }
95
+ const outcome = interpret(res, replay);
96
+ if (outcome.kind === 'delivered') alreadySent.add(msg.message_id);
97
+ return outcome;
98
+ }
@@ -0,0 +1,63 @@
1
+ import type { SessionInfo, SessionState } from './types.js';
2
+
3
+ export type EventEffect =
4
+ | { kind: 'upsert'; info: SessionInfo }
5
+ | { kind: 'state'; sessionID: string; state: SessionState }
6
+ | { kind: 'remove'; sessionID: string }
7
+ | { kind: 'ignore' };
8
+
9
+ const IGNORE: EventEffect = { kind: 'ignore' };
10
+
11
+ function readInfo(v: unknown): SessionInfo | null {
12
+ if (typeof v !== 'object' || v === null) return null;
13
+ const o = v as Record<string, unknown>;
14
+ if (
15
+ typeof o.id !== 'string' ||
16
+ typeof o.slug !== 'string' ||
17
+ typeof o.title !== 'string' ||
18
+ typeof o.directory !== 'string' ||
19
+ typeof o.version !== 'string'
20
+ ) {
21
+ return null;
22
+ }
23
+ return { id: o.id, slug: o.slug, title: o.title, directory: o.directory, version: o.version };
24
+ }
25
+
26
+ export function effectOf(event: unknown): EventEffect {
27
+ if (typeof event !== 'object' || event === null) return IGNORE;
28
+ const e = event as Record<string, unknown>;
29
+ const props = (typeof e.properties === 'object' && e.properties !== null ? e.properties : {}) as Record<string, unknown>;
30
+
31
+ switch (e.type) {
32
+ case 'session.created':
33
+ case 'session.updated': {
34
+ const info = readInfo(props.info);
35
+ return info ? { kind: 'upsert', info } : IGNORE;
36
+ }
37
+ case 'session.deleted': {
38
+ // Read leniently: a delete needs nothing but the id. Demanding the full
39
+ // create/update shape here means a payload missing (say) `version`
40
+ // returns `ignore`, so the record is never removed — and Tin Can then
41
+ // sees a peer whose socket is alive, so its liveness prune never fires
42
+ // and every message to it is dropped as `unknown session`. A peer that
43
+ // looks healthy and swallows input is worse than a stale one.
44
+ const id = (props.info as { id?: unknown } | undefined)?.id;
45
+ return typeof id === 'string' ? { kind: 'remove', sessionID: id } : IGNORE;
46
+ }
47
+ case 'session.idle': {
48
+ const id = props.sessionID;
49
+ return typeof id === 'string' ? { kind: 'state', sessionID: id, state: 'idle' } : IGNORE;
50
+ }
51
+ case 'session.status': {
52
+ const id = props.sessionID;
53
+ if (typeof id !== 'string') return IGNORE;
54
+ const status = props.status as { type?: unknown } | undefined;
55
+ // Only 'idle' is idle. 'busy', 'retry' and anything opencode adds later
56
+ // are all "the agent is not free". SPEC §5.
57
+ const state: SessionState = status?.type === 'idle' ? 'idle' : 'busy';
58
+ return { kind: 'state', sessionID: id, state };
59
+ }
60
+ default:
61
+ return IGNORE;
62
+ }
63
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Logging with a hard whitelist. SPEC §8.2: the plugin must never write
3
+ * message bodies into opencode's logs. Enforced here rather than remembered
4
+ * at every call site.
5
+ */
6
+
7
+ export interface LogFields {
8
+ event: string;
9
+ session?: string;
10
+ from?: string;
11
+ delivery?: string;
12
+ message_id?: string;
13
+ status?: number;
14
+ detail?: string;
15
+ }
16
+
17
+ export type Logger = (fields: LogFields) => void;
18
+
19
+ const ORDER = ['event', 'session', 'from', 'delivery', 'message_id', 'status', 'detail'] as const;
20
+
21
+ /**
22
+ * Wrap a callback so its own failure can never reach the host. SPEC §8.1.
23
+ *
24
+ * The one place this pattern lives: every host- or caller-supplied callback
25
+ * in the plugin is wrapped exactly once, where it is captured, and is then
26
+ * called bare everywhere else. Three separate hand-rolled versions of this
27
+ * used to sit in log.ts, plugin.ts and server.ts, with call sites that
28
+ * disagreed about which of them applied.
29
+ */
30
+ export function swallow<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => void {
31
+ return (...args: A) => {
32
+ try {
33
+ fn(...args);
34
+ } catch {
35
+ // Deliberate. A logging or error-reporting failure is not worth a
36
+ // wedged opencode session.
37
+ }
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Values are quoted when they contain a space, an `=` or a quote.
43
+ *
44
+ * `message_from` is peer-controlled and lands in `from=`. Unquoted, a sender
45
+ * calling itself `x delivery=steer session=ses_victim` forges fields in a
46
+ * line an operator reads during an incident.
47
+ */
48
+ function renderValue(value: unknown): string {
49
+ let rendered = String(value)
50
+ // One event is always one line. U+2028 and U+2029 are line
51
+ // terminators too — to a log viewer, to a terminal, and to JS itself.
52
+ .replace(/\s*[\r\n\u2028\u2029]+\s*/g, ' ');
53
+ if (rendered.length > 120) {
54
+ rendered = rendered.slice(0, 120) + '…';
55
+ }
56
+ if (/[\s="]/.test(rendered)) {
57
+ rendered = `"${rendered.replace(/"/g, '\\"')}"`;
58
+ }
59
+ return rendered;
60
+ }
61
+
62
+ export function formatLog(fields: LogFields): string {
63
+ const parts: string[] = [];
64
+ for (const key of ORDER) {
65
+ const value = (fields as unknown as Record<string, unknown>)[key];
66
+ if (value === undefined || value === null) continue;
67
+ parts.push(`${key}=${renderValue(value)}`);
68
+ }
69
+ return `[tincan] ${parts.join(' ')}`;
70
+ }
71
+
72
+ export function makeLogger(sink: (line: string) => void): Logger {
73
+ return swallow((fields: LogFields) => sink(formatLog(fields)));
74
+ }
@@ -0,0 +1,40 @@
1
+ import { join } from 'node:path';
2
+
3
+ /**
4
+ * macOS caps sun_path at 104 bytes including the NUL terminator; 102 bytes
5
+ * binds and 106 fails on the verified machine. Guard at 103. SPEC §4.
6
+ */
7
+ export const MAX_UNIX_PATH = 103;
8
+
9
+ function tincanHome(env: Record<string, string | undefined>, home: string): string {
10
+ return env.TINCAN_HOME && env.TINCAN_HOME.length > 0 ? env.TINCAN_HOME : join(home, '.tincan');
11
+ }
12
+
13
+ export function peersDir(env: Record<string, string | undefined>, home: string): string {
14
+ return join(tincanHome(env, home), 'peers', 'opencode');
15
+ }
16
+
17
+ /**
18
+ * Deliberately BESIDE `peers/`, not inside `peers/opencode/`: that directory
19
+ * is scanned by Tin Can and swept by `sweepOrphans`, and a log file dropped
20
+ * in it would confuse both.
21
+ */
22
+ export function pluginLogPath(env: Record<string, string | undefined>, home: string): string {
23
+ return join(tincanHome(env, home), 'opencode-plugin.log');
24
+ }
25
+
26
+ export function sessionFile(dir: string, sessionID: string): string {
27
+ return join(dir, `${sessionID}.json`);
28
+ }
29
+
30
+ export function socketPath(dir: string, instanceID: string): string {
31
+ return join(dir, `${instanceID}.sock`);
32
+ }
33
+
34
+ export function socketPathTooLong(path: string): boolean {
35
+ return Buffer.byteLength(path, 'utf8') >= MAX_UNIX_PATH;
36
+ }
37
+
38
+ export function newInstanceId(randomHex: () => string): string {
39
+ return `inst-${randomHex()}`;
40
+ }
@@ -0,0 +1,239 @@
1
+ import { composeCaller, isTincanTool, writeCaller } from './caller.js';
2
+ import { deliver } from './delivery.js';
3
+ import { effectOf } from './events.js';
4
+ import { makeLogger, swallow, type Logger } from './log.js';
5
+ import { socketPath } from './paths.js';
6
+ import {
7
+ composeRecord, isoStamp, removeAllForInstance, removeRecord,
8
+ sameIgnoringTimestamp, sweepOrphans, writeRecord, type RecordContext,
9
+ } from './registry.js';
10
+ import { listenLines, probeSocket, type ServerHandle } from './server.js';
11
+ import { PLUGIN_VERSION, type RegistryRecord, type SessionInfo, type SessionState, type Transport } from './types.js';
12
+ import { parseLine } from './wire.js';
13
+
14
+ export interface LineHandlerDeps {
15
+ transport: Transport;
16
+ /** Sessions this process heard announced. Anything else is not addressable. */
17
+ known: Map<string, RegistryRecord>;
18
+ sent: Set<string>;
19
+ log: Logger;
20
+ }
21
+
22
+ export function makeLineHandler(deps: LineHandlerDeps): (line: string) => Promise<void> {
23
+ // Wrapped once, here, because deps.log is caller-supplied; called bare
24
+ // everywhere below. SPEC §8.1.
25
+ const log = swallow(deps.log);
26
+ return async (line: string): Promise<void> => {
27
+ try {
28
+ const parsed = parseLine(line);
29
+ if (!parsed.ok) {
30
+ log({ event: 'dropped', detail: parsed.reason });
31
+ return;
32
+ }
33
+ const msg = parsed.message;
34
+ if (!deps.known.has(msg.to_session)) {
35
+ log({
36
+ event: 'dropped',
37
+ session: msg.to_session,
38
+ from: msg.message_from,
39
+ message_id: msg.message_id,
40
+ detail: 'unknown session',
41
+ });
42
+ return;
43
+ }
44
+ const outcome = await deliver(deps.transport, msg, deps.sent);
45
+ log({
46
+ event: outcome.kind === 'delivered' ? (outcome.replay ? 'replay' : 'delivered') : outcome.kind,
47
+ session: msg.to_session,
48
+ from: msg.message_from,
49
+ delivery: msg.delivery,
50
+ message_id: msg.message_id,
51
+ status: outcome.kind === 'rejected' ? outcome.status : undefined,
52
+ detail:
53
+ outcome.kind === 'rejected' ? outcome.tag
54
+ : outcome.kind === 'transport-broken' ? outcome.detail
55
+ : undefined,
56
+ });
57
+ } catch (e) {
58
+ // Nothing here may reach the host. SPEC §8.1.
59
+ log({ event: 'handler.failed', detail: String(e) });
60
+ }
61
+ };
62
+ }
63
+
64
+ export interface PluginDeps {
65
+ dir: string;
66
+ instanceId: string;
67
+ pid: number;
68
+ transport: Transport;
69
+ now: () => Date;
70
+ sink: (line: string) => void;
71
+ }
72
+
73
+ export interface PluginHooks {
74
+ event: (input: { event: unknown }) => Promise<void>;
75
+ 'tool.execute.before': (input: unknown) => Promise<void>;
76
+ dispose: () => Promise<void>;
77
+ }
78
+
79
+ async function selfCheck(transport: Transport, log: Logger): Promise<boolean> {
80
+ try {
81
+ const res = await transport.get({ url: '/api/session' });
82
+ if (typeof res.data === 'string') {
83
+ log({ event: 'selfcheck.failed', detail: 'html response' });
84
+ return false;
85
+ }
86
+ if ((res.response?.status ?? 0) !== 200) {
87
+ log({ event: 'selfcheck.failed', status: res.response?.status });
88
+ return false;
89
+ }
90
+ return true;
91
+ } catch (e) {
92
+ log({ event: 'selfcheck.failed', detail: String(e) });
93
+ return false;
94
+ }
95
+ }
96
+
97
+ export async function startPlugin(deps: PluginDeps): Promise<PluginHooks> {
98
+ const log = makeLogger(deps.sink);
99
+ const known = new Map<string, RegistryRecord>();
100
+ const sent = new Set<string>();
101
+ let server: ServerHandle | null = null;
102
+
103
+ const sock = socketPath(deps.dir, deps.instanceId);
104
+ const ctx: RecordContext = {
105
+ socket: sock,
106
+ instance_id: deps.instanceId,
107
+ pid: deps.pid,
108
+ plugin_version: PLUGIN_VERSION,
109
+ now: deps.now,
110
+ };
111
+
112
+ const handleLine = makeLineHandler({ transport: deps.transport, known, sent, log });
113
+
114
+ if (await selfCheck(deps.transport, log)) {
115
+ try {
116
+ const swept = await sweepOrphans(deps.dir, deps.instanceId, probeSocket);
117
+ if (swept.length > 0) log({ event: 'swept', detail: swept.join(',') });
118
+ server = await listenLines({
119
+ path: sock,
120
+ onLine: (line) => { void handleLine(line); },
121
+ onError: (e) => log({ event: 'socket.error', detail: String(e) }),
122
+ });
123
+ log({ event: 'bound', detail: sock });
124
+ } catch (e) {
125
+ log({ event: 'bind.failed', detail: String(e) });
126
+ server = null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Every mutation of `known` and of the registry directory runs through this
132
+ * one chain. opencode dispatches events without awaiting the previous one,
133
+ * and unserialised `set`-then-write against `delete`-then-unlink interleaves
134
+ * into a deleted session whose file survives with a live-looking state —
135
+ * which `known` no longer holds, so nothing ever rewrites or removes it
136
+ * again. `then(fn, fn)` rather than `then(fn)`: a rejected link must not
137
+ * stall the chain behind it.
138
+ */
139
+ let queue: Promise<unknown> = Promise.resolve();
140
+ const serial = <T>(fn: () => Promise<T>): Promise<T> => {
141
+ const next = queue.then(fn, fn);
142
+ queue = next;
143
+ return next;
144
+ };
145
+
146
+ /** Write only when something other than the timestamp changed: session.updated
147
+ * fires repeatedly while the model rewrites the title. SPEC §5.
148
+ *
149
+ * `known` is updated only AFTER the write lands. Updating it first makes a
150
+ * failed write poison the dedup cache: the next identical event compares
151
+ * equal against memory and is skipped, leaving disk permanently stale. */
152
+ const applyRecord = async (candidate: RegistryRecord): Promise<void> => {
153
+ const prev = known.get(candidate.session_id);
154
+ if (prev && sameIgnoringTimestamp(prev, candidate)) return;
155
+ await writeRecord(deps.dir, candidate);
156
+ known.set(candidate.session_id, candidate);
157
+ };
158
+
159
+ const applyInfo = (info: SessionInfo): Promise<void> => serial(async () => {
160
+ const state = known.get(info.id)?.state ?? 'idle';
161
+ await applyRecord(composeRecord(info, state, ctx));
162
+ });
163
+
164
+ const applyState = (sessionID: string, state: SessionState): Promise<void> => serial(async () => {
165
+ const base = known.get(sessionID);
166
+ if (!base) return; // Never announced, so not addressable. SPEC §5.
167
+ await applyRecord({ ...base, state, updated_at: isoStamp(ctx.now()) });
168
+ });
169
+
170
+ const applyRemove = (sessionID: string): Promise<void> => serial(async () => {
171
+ known.delete(sessionID);
172
+ await removeRecord(deps.dir, sessionID);
173
+ });
174
+
175
+ return {
176
+ event: async (input: { event: unknown }): Promise<void> => {
177
+ if (!server) return; // Advertising without a delivery path would be a lie.
178
+ try {
179
+ // opencode's real contract wraps the payload as { event }. Tolerate a
180
+ // bare event too: getting this normalisation wrong produces silent
181
+ // inertness (effectOf sees no `type` and returns 'ignore' forever),
182
+ // the worst failure mode there is, and a future opencode change
183
+ // narrowing or widening the wrapper must not silently switch the
184
+ // plugin off again.
185
+ const event = (input as { event?: unknown } | null)?.event ?? input;
186
+ const effect = effectOf(event);
187
+ switch (effect.kind) {
188
+ case 'upsert':
189
+ await applyInfo(effect.info);
190
+ return;
191
+ case 'state':
192
+ await applyState(effect.sessionID, effect.state);
193
+ return;
194
+ case 'remove':
195
+ await applyRemove(effect.sessionID);
196
+ return;
197
+ default:
198
+ return;
199
+ }
200
+ } catch (e) {
201
+ log({ event: 'event.failed', detail: String(e) });
202
+ }
203
+ },
204
+
205
+ 'tool.execute.before': async (input: unknown): Promise<void> => {
206
+ if (!server) return; // No delivery path, so no session worth excluding.
207
+ try {
208
+ const i = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>;
209
+ if (typeof i.tool !== 'string' || typeof i.sessionID !== 'string') return;
210
+ if (!isTincanTool(i.tool)) return;
211
+ await writeCaller(deps.dir, composeCaller(i.sessionID, i.tool, ctx));
212
+ } catch (e) {
213
+ log({ event: 'caller.failed', detail: String(e) });
214
+ }
215
+ },
216
+
217
+ dispose: async (): Promise<void> => {
218
+ // Order matters. Stop accepting work BEFORE removing anything: a
219
+ // closed ServerHandle is still a truthy object, and the event hook's
220
+ // only gate is `if (!server) return`, so a fire-and-forget onLine
221
+ // dispatch racing dispose could otherwise resurrect a registry file
222
+ // pointing at a socket that no longer exists — exactly the
223
+ // undeliverable-entry state the self-check exists to prevent. Removing
224
+ // first left the same window open for an event already in flight.
225
+ // Idempotent: a second dispose() finds server already null.
226
+ const handle = server;
227
+ server = null;
228
+ known.clear();
229
+ try {
230
+ if (handle) await handle.close();
231
+ // Through the chain, so any write already queued lands before the
232
+ // sweep rather than after it.
233
+ await serial(() => removeAllForInstance(deps.dir, deps.instanceId));
234
+ } catch (e) {
235
+ log({ event: 'dispose.failed', detail: String(e) });
236
+ }
237
+ },
238
+ };
239
+ }
@@ -0,0 +1,161 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { sessionFile, socketPath } from './paths.js';
5
+ import type { RegistryRecord, SessionInfo, SessionState } from './types.js';
6
+
7
+ export interface RecordContext {
8
+ socket: string;
9
+ instance_id: string;
10
+ pid: number;
11
+ plugin_version: string;
12
+ now: () => Date;
13
+ }
14
+
15
+ /** ISO 8601 to whole seconds, as SPEC §4's example shows. */
16
+ export function isoStamp(d: Date): string {
17
+ return `${d.toISOString().slice(0, 19)}Z`;
18
+ }
19
+
20
+ export function composeRecord(info: SessionInfo, state: SessionState, ctx: RecordContext): RegistryRecord {
21
+ return {
22
+ session_id: info.id,
23
+ slug: info.slug,
24
+ title: info.title,
25
+ directory: info.directory,
26
+ state,
27
+ socket: ctx.socket,
28
+ instance_id: ctx.instance_id,
29
+ pid: ctx.pid,
30
+ plugin_version: ctx.plugin_version,
31
+ opencode_version: info.version,
32
+ updated_at: isoStamp(ctx.now()),
33
+ };
34
+ }
35
+
36
+ export function sameIgnoringTimestamp(a: RegistryRecord, b: RegistryRecord): boolean {
37
+ const { updated_at: _a, ...restA } = a;
38
+ const { updated_at: _b, ...restB } = b;
39
+ return JSON.stringify(restA) === JSON.stringify(restB);
40
+ }
41
+
42
+ /**
43
+ * Atomic: temp file in the same directory, then rename. SPEC §4.
44
+ *
45
+ * The temp suffix includes both the pid (disambiguates between processes)
46
+ * and a random token (disambiguates between two concurrent writes to the
47
+ * same path inside one process — the pid alone collides there).
48
+ */
49
+ export async function writeJsonAtomic(finalPath: string, value: unknown): Promise<void> {
50
+ const dir = dirname(finalPath);
51
+ // mkdir's `mode` is ignored when the directory already exists — and Tin Can
52
+ // itself may have created ~/.tincan/peers at 0755. chmod unconditionally, or
53
+ // the 0700 parent that closes the bind-to-chmod race in SPEC §8.3 is a
54
+ // fiction.
55
+ await mkdir(dir, { recursive: true, mode: 0o700 });
56
+ await chmod(dir, 0o700);
57
+ const tmp = `${finalPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
58
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
59
+ await rename(tmp, finalPath);
60
+ }
61
+
62
+ export async function writeRecord(dir: string, rec: RegistryRecord): Promise<void> {
63
+ await writeJsonAtomic(sessionFile(dir, rec.session_id), rec);
64
+ }
65
+
66
+ export async function removeRecord(dir: string, sessionID: string): Promise<void> {
67
+ try {
68
+ await unlink(sessionFile(dir, sessionID));
69
+ } catch {
70
+ // Already gone is the desired end state.
71
+ }
72
+ }
73
+
74
+ export async function removeAllForInstance(dir: string, instanceID: string): Promise<void> {
75
+ let names: string[];
76
+ try {
77
+ names = await readdir(dir);
78
+ } catch {
79
+ return;
80
+ }
81
+ for (const name of names) {
82
+ if (!name.endsWith('.json')) continue;
83
+ try {
84
+ const rec = JSON.parse(await readFile(join(dir, name), 'utf8')) as RegistryRecord;
85
+ if (rec.instance_id === instanceID) await unlink(join(dir, name));
86
+ } catch {
87
+ // Unreadable or unparseable: not ours to delete.
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Delete the socket and registry files of every instance whose socket refuses
94
+ * a connection. The instance id is fresh on every load, so without this a
95
+ * `kill -9` leaves files nobody will ever reclaim. SPEC §6.
96
+ *
97
+ * Sockets are enumerated directly rather than read off records: because the
98
+ * plugin advertises nothing at load (SPEC §5), a crashed instance that never
99
+ * saw a session event leaves a socket with no record pointing at it, and that
100
+ * is the common case, not an edge case.
101
+ */
102
+ export async function sweepOrphans(
103
+ dir: string,
104
+ selfInstance: string,
105
+ probe: (socketPath: string) => Promise<boolean>,
106
+ ): Promise<string[]> {
107
+ let names: string[];
108
+ try {
109
+ names = await readdir(dir);
110
+ } catch {
111
+ return [];
112
+ }
113
+
114
+ const instances = new Map<string, { files: string[]; socket: string }>();
115
+ const entryFor = (id: string) => {
116
+ let entry = instances.get(id);
117
+ if (!entry) {
118
+ entry = { files: [], socket: socketPath(dir, id) };
119
+ instances.set(id, entry);
120
+ }
121
+ return entry;
122
+ };
123
+
124
+ for (const name of names) {
125
+ if (name.endsWith('.sock')) {
126
+ // Only OUR sockets. This directory is plugin-owned today, but the
127
+ // moment another Tin Can component drops a socket beside ours,
128
+ // treating every *.sock as an abandoned instance would delete it on
129
+ // the next opencode start.
130
+ if (!name.startsWith('inst-')) continue;
131
+ const id = name.slice(0, -'.sock'.length);
132
+ if (id !== selfInstance) entryFor(id);
133
+ continue;
134
+ }
135
+ if (!name.endsWith('.json')) continue;
136
+ try {
137
+ const rec = JSON.parse(await readFile(join(dir, name), 'utf8')) as RegistryRecord;
138
+ if (typeof rec.instance_id !== 'string' || rec.instance_id === selfInstance) continue;
139
+ entryFor(rec.instance_id).files.push(join(dir, name));
140
+ } catch {
141
+ // Unreadable or unparseable: not ours to delete.
142
+ }
143
+ }
144
+
145
+ const swept: string[] = [];
146
+ for (const [instance, entry] of instances) {
147
+ let alive = false;
148
+ try {
149
+ alive = await probe(entry.socket);
150
+ } catch {
151
+ alive = false;
152
+ }
153
+ if (alive) continue;
154
+ for (const file of entry.files) {
155
+ try { await unlink(file); } catch { /* already gone */ }
156
+ }
157
+ try { await unlink(entry.socket); } catch { /* already gone */ }
158
+ swept.push(instance);
159
+ }
160
+ return swept;
161
+ }
@@ -0,0 +1,173 @@
1
+ import { chmod, mkdir, unlink } from 'node:fs/promises';
2
+ import { createServer, connect, type Server, type Socket } from 'node:net';
3
+ import { dirname } from 'node:path';
4
+ import { swallow } from './log.js';
5
+ import { socketPathTooLong } from './paths.js';
6
+ import { MAX_LINE_BYTES } from './wire.js';
7
+
8
+ /**
9
+ * Resource hygiene on our own listener, not rate limiting — SPEC §8.6 puts
10
+ * throttling in Tin Can, and nothing here counts or delays messages. What
11
+ * these bound is file descriptors and buffers held inside the opencode
12
+ * process: a leaking sender that connects and never closes would otherwise
13
+ * accumulate sockets, each able to hold MAX_LINE_BYTES of unterminated
14
+ * buffer, until fd exhaustion wedges the host — which SPEC §8.1 calls a
15
+ * worse outcome than a missed message.
16
+ */
17
+ export const MAX_CONNECTIONS = 64;
18
+ export const IDLE_TIMEOUT_MS = 30_000;
19
+
20
+ export interface ListenOptions {
21
+ path: string;
22
+ onLine: (line: string) => void;
23
+ onError: (err: unknown) => void;
24
+ /** Overridable so tests need not wait out the real one. */
25
+ idleTimeoutMs?: number;
26
+ }
27
+
28
+ export interface ServerHandle {
29
+ close(): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * `onError` is already wrapped with `swallow`, so it is safe to call bare.
34
+ * `onLine` is not: its failure has somewhere useful to go, so it is called
35
+ * inside a try that reports to `onError` rather than being swallowed.
36
+ */
37
+ interface Handlers {
38
+ onLine: (line: string) => void;
39
+ onError: (err: unknown) => void;
40
+ }
41
+
42
+ function frame(socket: Socket, handlers: Handlers): void {
43
+ socket.setEncoding('utf8');
44
+ let buf = '';
45
+ let overflowed = false;
46
+
47
+ const emit = (line: string) => {
48
+ if (line.length === 0) return;
49
+ try {
50
+ handlers.onLine(line);
51
+ } catch (e) {
52
+ // A handler failure must never reach the host. SPEC §8.1.
53
+ handlers.onError(e);
54
+ }
55
+ };
56
+
57
+ socket.on('data', (chunk: string) => {
58
+ buf += chunk;
59
+ let i: number;
60
+ while ((i = buf.indexOf('\n')) >= 0) {
61
+ const line = buf.slice(0, i);
62
+ buf = buf.slice(i + 1);
63
+ if (overflowed) { overflowed = false; continue; }
64
+ if (Buffer.byteLength(line, 'utf8') >= MAX_LINE_BYTES) {
65
+ handlers.onError(new Error('oversize line dropped'));
66
+ continue;
67
+ }
68
+ emit(line);
69
+ }
70
+ if (Buffer.byteLength(buf, 'utf8') >= MAX_LINE_BYTES) {
71
+ handlers.onError(new Error('oversize line dropped'));
72
+ buf = '';
73
+ overflowed = true;
74
+ }
75
+ });
76
+
77
+ socket.on('end', () => {
78
+ if (!overflowed && buf.length > 0) emit(buf);
79
+ buf = '';
80
+ });
81
+ socket.on('error', (e) => handlers.onError(e));
82
+ }
83
+
84
+ export async function listenLines(opts: ListenOptions): Promise<ServerHandle> {
85
+ if (socketPathTooLong(opts.path)) {
86
+ throw new Error(`socket path too long (${Buffer.byteLength(opts.path)} bytes): ${opts.path}`);
87
+ }
88
+ const dir = dirname(opts.path);
89
+ await mkdir(dir, { recursive: true, mode: 0o700 });
90
+ // mkdir's `mode` is ignored when the directory already exists, and this
91
+ // parent is shared across every instance and every restart — so without
92
+ // an unconditional chmod, the 0700 protection SPEC §8.3 calls load-bearing
93
+ // only ever applies on a machine's first run. Matches registry.ts's
94
+ // writeRecord.
95
+ await chmod(dir, 0o700);
96
+ try {
97
+ await unlink(opts.path);
98
+ } catch {
99
+ // Nothing there is the common case.
100
+ }
101
+
102
+ const sockets = new Set<Socket>();
103
+ const idleMs = opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
104
+ // Wrapped exactly once, here, and called bare from then on. A caller's
105
+ // onError can itself throw (a broken logging sink, say) and that must
106
+ // never propagate out of a synchronous EventEmitter callback — SPEC §8.1
107
+ // is absolute, and this module is its strictest instance.
108
+ const handlers: Handlers = { onLine: opts.onLine, onError: swallow(opts.onError) };
109
+ const server: Server = createServer((socket) => {
110
+ sockets.add(socket);
111
+ socket.on('close', () => sockets.delete(socket));
112
+ // A sender writes one line and closes. Anything still idle after this
113
+ // is a leak, not a peer.
114
+ socket.setTimeout(idleMs, () => socket.destroy());
115
+ frame(socket, handlers);
116
+ });
117
+ server.maxConnections = MAX_CONNECTIONS;
118
+ server.on('error', (e) => handlers.onError(e));
119
+
120
+ await new Promise<void>((resolve, reject) => {
121
+ server.once('error', reject);
122
+ server.listen(opts.path, () => resolve());
123
+ });
124
+
125
+ const closeServer = () => new Promise<void>((resolve) => server.close(() => resolve()));
126
+
127
+ try {
128
+ // Neither node:net nor Bun.listen honours 0600 on creation. SPEC §4.
129
+ await chmod(opts.path, 0o600);
130
+ } catch (e) {
131
+ // Rethrowing from here would leave a listening server on a 0755 socket
132
+ // that no ServerHandle exists to close.
133
+ await closeServer();
134
+ try { await unlink(opts.path); } catch { /* already gone */ }
135
+ throw e;
136
+ }
137
+
138
+ let closed = false;
139
+ return {
140
+ close: async () => {
141
+ if (closed) return;
142
+ closed = true;
143
+ // server.close() only stops new connections and waits for existing
144
+ // ones to end on their own — it never terminates them. A single idle
145
+ // peer would otherwise wedge this forever, and SPEC §8.1 names
146
+ // wedging the user's session as worse than a missed message.
147
+ for (const socket of sockets) socket.destroy();
148
+ await closeServer();
149
+ try {
150
+ await unlink(opts.path);
151
+ } catch {
152
+ // Already gone.
153
+ }
154
+ },
155
+ };
156
+ }
157
+
158
+ /** The liveness test the whole staleness model rests on. SPEC §6. */
159
+ export function probeSocket(path: string, timeoutMs = 250): Promise<boolean> {
160
+ return new Promise((resolve) => {
161
+ let done = false;
162
+ const finish = (alive: boolean) => {
163
+ if (done) return;
164
+ done = true;
165
+ try { c.destroy(); } catch { /* already gone */ }
166
+ resolve(alive);
167
+ };
168
+ const c = connect(path);
169
+ c.setTimeout(timeoutMs, () => finish(false));
170
+ c.on('connect', () => finish(true));
171
+ c.on('error', () => finish(false));
172
+ });
173
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Hand-written types for the slice of opencode 1.18.31 this plugin touches.
3
+ *
4
+ * Deliberately NOT imported from @opencode-ai/plugin: its published types
5
+ * disagree with the 1.18.31 runtime in both directions — they declare a
6
+ * `client.v2` that does not exist, and omit the `slug` that does.
7
+ * See SPEC.md §2.
8
+ */
9
+
10
+ /** Tracks the Tin Can package version: every registry record reports it,
11
+ * and a number matching no release tells an operator nothing. */
12
+ export const PLUGIN_VERSION = '0.6.0';
13
+ export const OPENCODE_TESTED_VERSION = '1.18.31';
14
+
15
+ /** Server-enforced: sessionID must match ^ses, message id must match ^msg_. */
16
+ export const SESSION_ID_RE = /^ses/;
17
+ export const MESSAGE_ID_RE = /^msg_/;
18
+
19
+ /** No 'unreachable': the plugin cannot observe its own absence. Tin Can infers
20
+ * that from a refused socket and caches it in its own view. SPEC §4. */
21
+ export type SessionState = 'idle' | 'busy';
22
+ export type Delivery = 'queue' | 'steer';
23
+
24
+ /** The subset of opencode's Session we rely on. `slug` and `version` are
25
+ * present at runtime on event payloads even though the SDK type omits them. */
26
+ export interface SessionInfo {
27
+ id: string;
28
+ slug: string;
29
+ title: string;
30
+ directory: string;
31
+ version: string;
32
+ }
33
+
34
+ export interface RegistryRecord {
35
+ session_id: string;
36
+ slug: string;
37
+ title: string;
38
+ directory: string;
39
+ state: SessionState;
40
+ socket: string;
41
+ instance_id: string;
42
+ pid: number;
43
+ plugin_version: string;
44
+ opencode_version: string;
45
+ updated_at: string;
46
+ }
47
+
48
+ export interface InboundMessage {
49
+ to_session: string;
50
+ message_from: string;
51
+ text: string;
52
+ delivery: Delivery;
53
+ message_id: string;
54
+ }
55
+
56
+ /** The hey-api client reachable at `input.client._client`. See SPEC.md §3. */
57
+ export interface TransportResponse {
58
+ data?: unknown;
59
+ error?: unknown;
60
+ response?: { status?: number };
61
+ }
62
+ export interface Transport {
63
+ get(args: { url: string }): Promise<TransportResponse>;
64
+ post(args: { url: string; body?: unknown }): Promise<TransportResponse>;
65
+ }
66
+
67
+ export type DeliveryOutcome =
68
+ | { kind: 'delivered'; replay: boolean }
69
+ | { kind: 'rejected'; status: number; tag: string; detail: string }
70
+ | { kind: 'transport-broken'; detail: string };
@@ -0,0 +1,73 @@
1
+ import { MESSAGE_ID_RE, SESSION_ID_RE, type Delivery, type InboundMessage } from './types.js';
2
+
3
+ /** One line, one message. Anything larger is a sender bug or an attack. */
4
+ export const MAX_LINE_BYTES = 256 * 1024;
5
+
6
+ /** Session and message IDs must be bounded to prevent body leakage via overlong ids. */
7
+ export const MAX_ID_BYTES = 128;
8
+
9
+ /**
10
+ * The opening token of Tin Can's provenance envelope.
11
+ *
12
+ * Only the token, never the whole tag: `src/envelope.ts` emits
13
+ * `<peer_message from="…" runtime="…" id="…">` and those attributes are free
14
+ * to change. What must not change is that the envelope is there at all —
15
+ * SPEC §7 makes it the load-bearing safety control on this path, and without
16
+ * this check a Tin Can regression that stopped enveloping would inject text
17
+ * indistinguishable from the operator's own with nothing noticing.
18
+ */
19
+ export const ENVELOPE_TOKEN = '<peer_message';
20
+
21
+ export type ParseResult =
22
+ | { ok: true; message: InboundMessage }
23
+ | { ok: false; reason: string };
24
+
25
+ function str(v: unknown): v is string {
26
+ return typeof v === 'string' && v.length > 0;
27
+ }
28
+
29
+ export function parseLine(line: string): ParseResult {
30
+ if (Buffer.byteLength(line, 'utf8') >= MAX_LINE_BYTES) return { ok: false, reason: 'oversize' };
31
+
32
+ let raw: unknown;
33
+ try {
34
+ raw = JSON.parse(line);
35
+ } catch {
36
+ return { ok: false, reason: 'malformed json' };
37
+ }
38
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
39
+ return { ok: false, reason: 'not an object' };
40
+ }
41
+ const o = raw as Record<string, unknown>;
42
+
43
+ if (!str(o.to_session)) return { ok: false, reason: 'missing to_session' };
44
+ if (!str(o.message_from)) return { ok: false, reason: 'missing message_from' };
45
+ if (!str(o.text)) return { ok: false, reason: 'missing text' };
46
+ if (!str(o.delivery)) return { ok: false, reason: 'missing delivery' };
47
+ if (!str(o.message_id)) return { ok: false, reason: 'missing message_id' };
48
+
49
+ // Presence only. The plugin never inspects, trims or conditions the
50
+ // envelope's contents — that stays Tin Can's. SPEC §7.
51
+ if (!o.text.includes(ENVELOPE_TOKEN)) return { ok: false, reason: 'missing envelope' };
52
+
53
+ if (!SESSION_ID_RE.test(o.to_session)) return { ok: false, reason: 'bad to_session' };
54
+ if (Buffer.byteLength(o.to_session, 'utf8') > MAX_ID_BYTES) return { ok: false, reason: 'bad to_session' };
55
+ if (!MESSAGE_ID_RE.test(o.message_id)) return { ok: false, reason: 'bad message_id' };
56
+ if (Buffer.byteLength(o.message_id, 'utf8') > MAX_ID_BYTES) return { ok: false, reason: 'bad message_id' };
57
+ // message_from is peer-controlled and reaches the operator's log. Bound it
58
+ // like the ids, or it is the one wire field a sender can use to push an
59
+ // arbitrary amount of its own text into a log line.
60
+ if (Buffer.byteLength(o.message_from, 'utf8') > MAX_ID_BYTES) return { ok: false, reason: 'bad message_from' };
61
+ if (o.delivery !== 'queue' && o.delivery !== 'steer') return { ok: false, reason: 'bad delivery' };
62
+
63
+ return {
64
+ ok: true,
65
+ message: {
66
+ to_session: o.to_session,
67
+ message_from: o.message_from,
68
+ text: o.text,
69
+ delivery: o.delivery as Delivery,
70
+ message_id: o.message_id,
71
+ },
72
+ };
73
+ }
package/tincan.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tin Can — opencode plugin.
3
+ *
4
+ * WARNING: opencode's loader invokes EVERY exported function in this file as a
5
+ * plugin, and does not descend into subdirectories. Export exactly one thing,
6
+ * and never a `default`. All logic lives in ./tincan-lib/. See SPEC.md §2.
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname } from 'node:path';
12
+ import { newInstanceId, peersDir, pluginLogPath } from './tincan-lib/paths.js';
13
+ import { startPlugin } from './tincan-lib/plugin.js';
14
+ import type { Transport } from './tincan-lib/types.js';
15
+
16
+ /** Rotates to `<log>.1` past this. Small enough to stay cheap to read, big
17
+ * enough to hold a long session's diagnostics. */
18
+ const MAX_LOG_BYTES = 4 * 1024 * 1024;
19
+
20
+ export const TinCan = async (input: { client: { _client?: unknown } }) => {
21
+ // console.error would land in the TUI's own terminal — the same one
22
+ // opencode is drawing its interface on — and nowhere else: it never
23
+ // reaches opencode's own log file. Appending to our own log file is the
24
+ // only way an operator can read event=selfcheck.failed / bind.failed /
25
+ // transport-broken after the session ends. makeLogger already guards the
26
+ // call into this sink, but the sink itself must not throw either — a
27
+ // logging failure (e.g. an unwritable disk) must never reach the host.
28
+ const logPath = pluginLogPath(process.env, homedir());
29
+ // Every other artefact here is owner-only (peers dir 0700, socket 0600,
30
+ // records 0600). This log holds no message bodies, but it does hold
31
+ // session ids, peer names, message ids and delivery modes — a record of
32
+ // who is messaging whom — so it gets the same treatment. `mode` on
33
+ // appendFileSync only takes effect when the file is created, so a chmod
34
+ // follows the first write to any given file — a pre-existing 0644 log
35
+ // gets tightened too, not just a freshly created one.
36
+ //
37
+ // This runs on the TUI's worker thread, so it is kept to ONE syscall per
38
+ // line in the steady state: the mkdir, the chmod and the size check happen
39
+ // on the first write only, and the size is tracked in memory after that.
40
+ let logBytes = -1; // -1 until the directory is ensured and the size read
41
+ let tightened = false; // chmod applied to the file currently at logPath
42
+ const sink = (line: string) => {
43
+ try {
44
+ const data = `${line}\n`;
45
+ if (logBytes < 0) {
46
+ mkdirSync(dirname(logPath), { recursive: true });
47
+ try { logBytes = statSync(logPath).size; } catch { logBytes = 0; }
48
+ }
49
+ if (logBytes >= MAX_LOG_BYTES) {
50
+ // One generation back, then overwritten. Two bounded files beat one
51
+ // unbounded one, and a rotation that fails must not cost us the
52
+ // line — logBytes is reset either way so we do not retry per line.
53
+ try {
54
+ renameSync(logPath, `${logPath}.1`);
55
+ chmodSync(`${logPath}.1`, 0o600);
56
+ tightened = false;
57
+ } catch { /* keep appending */ }
58
+ logBytes = 0;
59
+ }
60
+ appendFileSync(logPath, data, { mode: 0o600 });
61
+ logBytes += Buffer.byteLength(data);
62
+ if (!tightened) {
63
+ chmodSync(logPath, 0o600);
64
+ tightened = true;
65
+ }
66
+ } catch {
67
+ // Nothing here may reach the host. SPEC §8.1.
68
+ }
69
+ };
70
+ // The only private-field dependency in the plugin. SPEC §3 explains why it
71
+ // is unavoidable; the self-check inside startPlugin turns a future breakage
72
+ // into "no opencode peers" rather than a crash.
73
+ const transport = input.client?._client;
74
+
75
+ if (!transport || typeof (transport as { post?: unknown }).post !== 'function') {
76
+ sink('[tincan] event=selfcheck.failed detail=no transport on client._client');
77
+ return {};
78
+ }
79
+
80
+ return startPlugin({
81
+ dir: peersDir(process.env, homedir()),
82
+ instanceId: newInstanceId(() => randomBytes(3).toString('hex')),
83
+ pid: process.pid,
84
+ transport: transport as Transport,
85
+ now: () => new Date(),
86
+ sink,
87
+ });
88
+ };