@brutalsystems/tincan-opencode 0.8.0 → 0.9.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/README.md CHANGED
@@ -8,7 +8,9 @@ The plugin receives inbound messages delivered to a Unix socket and injects them
8
8
 
9
9
  ## Requirements
10
10
 
11
- - **opencode** 1.18.31 (verified; other versions untested)
11
+ - **opencode** 1.18.31 (fully verified). **1.18.32** re-verified for the
12
+ transport contract the plugin depends on — see SPEC.md §3. Other versions
13
+ untested.
12
14
  - **Tin Can** 0.4.0 or later
13
15
 
14
16
  ## Install
@@ -94,10 +96,10 @@ tail -f "$TINCAN_HOME/opencode-plugin.log"
94
96
 
95
97
  | Symptom | Cause | Action |
96
98
  |---------|-------|--------|
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. |
99
+ | **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. `_client` is present and carries `post`/`get`/`getConfig` on both 1.18.31 and 1.18.32; a later version is the thing to suspect. See SPEC.md §3. |
98
100
  | **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
101
  | **`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. |
102
+ | **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 or 1.18.32. Check the plugin source to ensure `POST /api/session/{sessionID}/prompt` is the exact path. |
101
103
  | **`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
104
  | **`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
105
  | **`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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/tincan-opencode",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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
5
  "keywords": [
6
6
  "opencode",
@@ -1,3 +1,4 @@
1
+ import { unlink } from 'node:fs/promises';
1
2
  import { join } from 'node:path';
2
3
  import { isoStamp, writeJsonAtomic, type RecordContext } from './registry.js';
3
4
 
@@ -47,3 +48,69 @@ export function composeCaller(sessionID: string, toolID: string, ctx: RecordCont
47
48
  export async function writeCaller(dir: string, rec: CallerRecord): Promise<void> {
48
49
  await writeJsonAtomic(callerFile(dir, rec.instance_id), rec);
49
50
  }
51
+
52
+ /**
53
+ * A per-call ticket, written when a Tin Can tool starts and removed when it
54
+ * ends. Tin Can reads these to identify the calling session *certainly*
55
+ * rather than by recency: its own call is in flight by definition, so exactly
56
+ * one fresh ticket for our pid can only be ours (#3).
57
+ *
58
+ * Kept ALONGSIDE the single caller file above, not instead of it. The plugin
59
+ * installs separately from the core, so an older core that only knows
60
+ * `inst-<id>.caller.json` must keep working exactly as it does today. The
61
+ * distinct `.call.json` suffix also keeps these invisible to that core's
62
+ * `.caller.json` glob.
63
+ *
64
+ * They still carry `instance_id`, so `dispose` and the orphan sweep reclaim
65
+ * them by the same path as every other file here.
66
+ */
67
+ export interface CallTicket extends CallerRecord {
68
+ call_id: string;
69
+ }
70
+
71
+ /**
72
+ * opencode's callID is an opaque host string and this makes it a path
73
+ * segment, so it is constrained rather than trusted: anything outside
74
+ * `[A-Za-z0-9_-]` becomes `-`, and the result is capped. A `.` would collide
75
+ * with the suffix scheme and `/` or `..` would escape the directory.
76
+ */
77
+ export function sanitizeCallId(callID: string): string {
78
+ const safe = callID.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 64);
79
+ return safe.length > 0 ? safe : 'anon';
80
+ }
81
+
82
+ /** Distinct from `<instance>.caller.json`, which an older core still reads. */
83
+ export function callTicketFile(dir: string, instanceID: string, callID: string): string {
84
+ return join(dir, `${instanceID}.${sanitizeCallId(callID)}.call.json`);
85
+ }
86
+
87
+ export function composeCallTicket(
88
+ sessionID: string,
89
+ toolID: string,
90
+ callID: string,
91
+ ctx: RecordContext,
92
+ ): CallTicket {
93
+ return { ...composeCaller(sessionID, toolID, ctx), call_id: sanitizeCallId(callID) };
94
+ }
95
+
96
+ export async function writeCallTicket(dir: string, rec: CallTicket): Promise<void> {
97
+ await writeJsonAtomic(callTicketFile(dir, rec.instance_id, rec.call_id), rec);
98
+ }
99
+
100
+ /**
101
+ * opencode fires `tool.execute.after` only when the call succeeded — an error,
102
+ * a denied permission or an abort skip it entirely [verified, 1.18.32], so
103
+ * tickets leak by design and the reader expires them. This is the tidy path,
104
+ * not the guarantee.
105
+ */
106
+ export async function removeCallTicket(
107
+ dir: string,
108
+ instanceID: string,
109
+ callID: string,
110
+ ): Promise<void> {
111
+ try {
112
+ await unlink(callTicketFile(dir, instanceID, callID));
113
+ } catch {
114
+ // Already gone: a sweep, a dispose, or never written.
115
+ }
116
+ }
@@ -1,4 +1,11 @@
1
- import { composeCaller, isTincanTool, writeCaller } from './caller.js';
1
+ import {
2
+ composeCallTicket,
3
+ composeCaller,
4
+ isTincanTool,
5
+ removeCallTicket,
6
+ writeCallTicket,
7
+ writeCaller,
8
+ } from './caller.js';
2
9
  import { deliver } from './delivery.js';
3
10
  import { effectOf } from './events.js';
4
11
  import { makeLogger, swallow, type Logger } from './log.js';
@@ -9,7 +16,7 @@ import {
9
16
  } from './registry.js';
10
17
  import { listenLines, probeSocket, type ServerHandle } from './server.js';
11
18
  import { PLUGIN_VERSION, type RegistryRecord, type SessionInfo, type SessionState, type Transport } from './types.js';
12
- import { parseLine } from './wire.js';
19
+ import { parseLine, renderAck, type Ack } from './wire.js';
13
20
 
14
21
  export interface LineHandlerDeps {
15
22
  transport: Transport;
@@ -19,16 +26,21 @@ export interface LineHandlerDeps {
19
26
  log: Logger;
20
27
  }
21
28
 
22
- export function makeLineHandler(deps: LineHandlerDeps): (line: string) => Promise<void> {
29
+ /**
30
+ * Returns the ack the sender gets back. Every `return` here is a sender-visible
31
+ * answer, not just a log line — until 0.9.0 the only signal was "the bytes
32
+ * arrived", so a drop and a delivery were indistinguishable to Tin Can (#9).
33
+ */
34
+ export function makeLineHandler(deps: LineHandlerDeps): (line: string) => Promise<Ack> {
23
35
  // Wrapped once, here, because deps.log is caller-supplied; called bare
24
36
  // everywhere below. SPEC §8.1.
25
37
  const log = swallow(deps.log);
26
- return async (line: string): Promise<void> => {
38
+ return async (line: string): Promise<Ack> => {
27
39
  try {
28
40
  const parsed = parseLine(line);
29
41
  if (!parsed.ok) {
30
42
  log({ event: 'dropped', detail: parsed.reason });
31
- return;
43
+ return { ok: false, reason: `malformed frame: ${parsed.reason}` };
32
44
  }
33
45
  const msg = parsed.message;
34
46
  if (!deps.known.has(msg.to_session)) {
@@ -39,7 +51,11 @@ export function makeLineHandler(deps: LineHandlerDeps): (line: string) => Promis
39
51
  message_id: msg.message_id,
40
52
  detail: 'unknown session',
41
53
  });
42
- return;
54
+ return {
55
+ ok: false,
56
+ message_id: msg.message_id,
57
+ reason: `unknown session ${msg.to_session} on this opencode instance`,
58
+ };
43
59
  }
44
60
  const outcome = await deliver(deps.transport, msg, deps.sent);
45
61
  log({
@@ -54,9 +70,29 @@ export function makeLineHandler(deps: LineHandlerDeps): (line: string) => Promis
54
70
  : outcome.kind === 'transport-broken' ? outcome.detail
55
71
  : undefined,
56
72
  });
73
+ if (outcome.kind === 'delivered') {
74
+ return {
75
+ ok: true,
76
+ message_id: msg.message_id,
77
+ status: outcome.replay ? 'replay' : 'delivered',
78
+ };
79
+ }
80
+ return {
81
+ ok: false,
82
+ message_id: msg.message_id,
83
+ reason:
84
+ outcome.kind === 'rejected'
85
+ ? `opencode refused the prompt (status ${String(outcome.status)}${
86
+ outcome.tag === undefined ? '' : `: ${outcome.tag}`
87
+ })`
88
+ : `opencode transport failed: ${outcome.detail}`,
89
+ };
57
90
  } catch (e) {
58
- // Nothing here may reach the host. SPEC §8.1.
91
+ // Nothing here may reach the host. SPEC §8.1. The sender is told the
92
+ // message did not land rather than being left to infer it from a
93
+ // closed socket, which would read as success.
59
94
  log({ event: 'handler.failed', detail: String(e) });
95
+ return { ok: false, reason: `plugin handler failed: ${String(e)}` };
60
96
  }
61
97
  };
62
98
  }
@@ -73,6 +109,7 @@ export interface PluginDeps {
73
109
  export interface PluginHooks {
74
110
  event: (input: { event: unknown }) => Promise<void>;
75
111
  'tool.execute.before': (input: unknown) => Promise<void>;
112
+ 'tool.execute.after': (input: unknown) => Promise<void>;
76
113
  dispose: () => Promise<void>;
77
114
  }
78
115
 
@@ -94,6 +131,25 @@ async function selfCheck(transport: Transport, log: Logger): Promise<boolean> {
94
131
  }
95
132
  }
96
133
 
134
+ /**
135
+ * The fields both tool hooks need, or `undefined` when this is not a Tin Can
136
+ * tool call worth recording. Shared so the two hooks cannot drift into
137
+ * disagreeing about what counts — a ticket written by one and not removed by
138
+ * the other is a leak the reader then has to expire.
139
+ */
140
+ function tincanToolCall(
141
+ input: unknown,
142
+ ): { tool: string; sessionID: string; callID?: string } | undefined {
143
+ const i = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>;
144
+ if (typeof i.tool !== 'string' || typeof i.sessionID !== 'string') return undefined;
145
+ if (!isTincanTool(i.tool)) return undefined;
146
+ return {
147
+ tool: i.tool,
148
+ sessionID: i.sessionID,
149
+ ...(typeof i.callID === 'string' && i.callID.length > 0 && { callID: i.callID }),
150
+ };
151
+ }
152
+
97
153
  export async function startPlugin(deps: PluginDeps): Promise<PluginHooks> {
98
154
  const log = makeLogger(deps.sink);
99
155
  const known = new Map<string, RegistryRecord>();
@@ -117,7 +173,7 @@ export async function startPlugin(deps: PluginDeps): Promise<PluginHooks> {
117
173
  if (swept.length > 0) log({ event: 'swept', detail: swept.join(',') });
118
174
  server = await listenLines({
119
175
  path: sock,
120
- onLine: (line) => { void handleLine(line); },
176
+ onLine: (line) => handleLine(line).then(renderAck),
121
177
  onError: (e) => log({ event: 'socket.error', detail: String(e) }),
122
178
  });
123
179
  log({ event: 'bound', detail: sock });
@@ -205,10 +261,36 @@ export async function startPlugin(deps: PluginDeps): Promise<PluginHooks> {
205
261
  'tool.execute.before': async (input: unknown): Promise<void> => {
206
262
  if (!server) return; // No delivery path, so no session worth excluding.
207
263
  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;
264
+ const i = tincanToolCall(input);
265
+ if (i === undefined) return;
266
+ // Both, deliberately. The caller file is what an older core reads,
267
+ // and the plugin installs separately from the core so that skew is
268
+ // normal. The ticket is what a current core prefers.
211
269
  await writeCaller(deps.dir, composeCaller(i.sessionID, i.tool, ctx));
270
+ if (i.callID !== undefined) {
271
+ await writeCallTicket(deps.dir, composeCallTicket(i.sessionID, i.tool, i.callID, ctx));
272
+ }
273
+ } catch (e) {
274
+ log({ event: 'caller.failed', detail: String(e) });
275
+ }
276
+ },
277
+
278
+ /**
279
+ * The tidy path only. opencode reaches this hook by falling off the end of
280
+ * a successful call — an error, a denied permission or an abort skip it
281
+ * [verified against 1.18.32's MCP tool wrapper, which has no `finally`].
282
+ * So a leaked ticket is expected, not exceptional, and the reader expires
283
+ * tickets rather than trusting this to have run.
284
+ *
285
+ * The caller file is deliberately NOT removed here: it is the older core's
286
+ * only signal, and it is overwritten rather than cleared by design.
287
+ */
288
+ 'tool.execute.after': async (input: unknown): Promise<void> => {
289
+ if (!server) return;
290
+ try {
291
+ const i = tincanToolCall(input);
292
+ if (i?.callID === undefined) return;
293
+ await removeCallTicket(deps.dir, deps.instanceId, i.callID);
212
294
  } catch (e) {
213
295
  log({ event: 'caller.failed', detail: String(e) });
214
296
  }
@@ -19,7 +19,12 @@ export const IDLE_TIMEOUT_MS = 30_000;
19
19
 
20
20
  export interface ListenOptions {
21
21
  path: string;
22
- onLine: (line: string) => void;
22
+ /**
23
+ * Resolves to the line to write back. A handler that answers nothing — or
24
+ * is not async at all — is still valid: the socket is closed either way,
25
+ * so a sender never waits on a listener that has nothing to say.
26
+ */
27
+ onLine: (line: string) => void | Promise<string | undefined>;
23
28
  onError: (err: unknown) => void;
24
29
  /** Overridable so tests need not wait out the real one. */
25
30
  idleTimeoutMs?: number;
@@ -35,7 +40,7 @@ export interface ServerHandle {
35
40
  * inside a try that reports to `onError` rather than being swallowed.
36
41
  */
37
42
  interface Handlers {
38
- onLine: (line: string) => void;
43
+ onLine: (line: string) => void | Promise<string | undefined>;
39
44
  onError: (err: unknown) => void;
40
45
  }
41
46
 
@@ -43,17 +48,45 @@ function frame(socket: Socket, handlers: Handlers): void {
43
48
  socket.setEncoding('utf8');
44
49
  let buf = '';
45
50
  let overflowed = false;
51
+ // A sender writes one line and half-closes, so one connection carries one
52
+ // message and earns one answer. `answered` also guarantees we end the
53
+ // socket exactly once: the server runs with allowHalfOpen, so nothing
54
+ // closes our side for us any more.
55
+ let answered = false;
56
+ let handled = 0;
46
57
 
47
- const emit = (line: string) => {
48
- if (line.length === 0) return;
58
+ const reply = (line: string | undefined) => {
59
+ if (answered) return;
60
+ answered = true;
49
61
  try {
50
- handlers.onLine(line);
62
+ if (socket.writableEnded || socket.destroyed) return;
63
+ if (line === undefined) socket.end();
64
+ else socket.end(`${line}\n`);
51
65
  } catch (e) {
52
- // A handler failure must never reach the host. SPEC §8.1.
66
+ // An old Tin Can stops reading and destroys the connection as soon as
67
+ // our side closes, so writing into it can EPIPE. That is the expected
68
+ // shape of version skew, not a fault. SPEC §8.1.
53
69
  handlers.onError(e);
54
70
  }
55
71
  };
56
72
 
73
+ const emit = (line: string) => {
74
+ if (line.length === 0) return;
75
+ handled++;
76
+ void (async () => {
77
+ let answer: string | undefined;
78
+ try {
79
+ const r = await handlers.onLine(line);
80
+ answer = typeof r === 'string' ? r : undefined;
81
+ } catch (e) {
82
+ // A handler failure must never reach the host. SPEC §8.1. The sender
83
+ // still gets its side closed, so it falls back rather than waiting.
84
+ handlers.onError(e);
85
+ }
86
+ reply(answer);
87
+ })();
88
+ };
89
+
57
90
  socket.on('data', (chunk: string) => {
58
91
  buf += chunk;
59
92
  let i: number;
@@ -77,6 +110,8 @@ function frame(socket: Socket, handlers: Handlers): void {
77
110
  socket.on('end', () => {
78
111
  if (!overflowed && buf.length > 0) emit(buf);
79
112
  buf = '';
113
+ // Nothing to answer and, with allowHalfOpen, nothing to close us either.
114
+ if (handled === 0) reply(undefined);
80
115
  });
81
116
  socket.on('error', (e) => handlers.onError(e));
82
117
  }
@@ -106,7 +141,13 @@ export async function listenLines(opts: ListenOptions): Promise<ServerHandle> {
106
141
  // never propagate out of a synchronous EventEmitter callback — SPEC §8.1
107
142
  // is absolute, and this module is its strictest instance.
108
143
  const handlers: Handlers = { onLine: opts.onLine, onError: swallow(opts.onError) };
109
- const server: Server = createServer((socket) => {
144
+ // allowHalfOpen is what makes an answer possible at all. Without it Node
145
+ // ends our writable side automatically the moment the sender's FIN lands —
146
+ // and the sender FINs immediately after writing its line, so by the time we
147
+ // have something to say the socket is already closing. The cost is that
148
+ // every path out of `frame` must end the socket itself; `reply` is that
149
+ // single exit, and the idle timeout below is the backstop.
150
+ const server: Server = createServer({ allowHalfOpen: true }, (socket) => {
110
151
  sockets.add(socket);
111
152
  socket.on('close', () => sockets.delete(socket));
112
153
  // A sender writes one line and closes. Anything still idle after this
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** Tracks the Tin Can package version: every registry record reports it,
11
11
  * and a number matching no release tells an operator nothing. */
12
- export const PLUGIN_VERSION = '0.8.0';
12
+ export const PLUGIN_VERSION = '0.9.0';
13
13
  export const OPENCODE_TESTED_VERSION = '1.18.31';
14
14
 
15
15
  /** Server-enforced: sessionID must match ^ses, message id must match ^msg_. */
@@ -71,3 +71,30 @@ export function parseLine(line: string): ParseResult {
71
71
  },
72
72
  };
73
73
  }
74
+
75
+ /**
76
+ * What the plugin writes back on the same connection before closing it.
77
+ *
78
+ * Until 0.9.0 the plugin answered nothing, so Tin Can counted a message as
79
+ * delivered the moment the bytes reached the socket — an unknown session, a
80
+ * malformed frame, a missing envelope and a 404 from opencode all looked
81
+ * identical to success (#9). `ok` is the difference between "we received it"
82
+ * and "we ran it".
83
+ *
84
+ * A refusal is a FAILURE here, not a success with a note. opencode has no
85
+ * equivalent of the Claude inbox's hold — where a human may still release the
86
+ * message, so `delivered: true` is honest — and a message this plugin refused
87
+ * will never be acted on by anyone.
88
+ */
89
+ export interface Ack {
90
+ ok: boolean;
91
+ message_id?: string;
92
+ /** Present when `ok`: `replay` means this id had already been delivered. */
93
+ status?: 'delivered' | 'replay';
94
+ /** Present when not `ok`: why, in terms a sender can act on. */
95
+ reason?: string;
96
+ }
97
+
98
+ export function renderAck(ack: Ack): string {
99
+ return JSON.stringify(ack);
100
+ }