@brutalsystems/tincan-opencode 0.8.0 → 0.9.1
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 +5 -3
- package/package.json +1 -1
- package/tincan-lib/caller.ts +67 -0
- package/tincan-lib/plugin.ts +93 -11
- package/tincan-lib/server.ts +48 -7
- package/tincan-lib/types.ts +1 -1
- package/tincan-lib/wire.ts +27 -0
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
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|
package/tincan-lib/caller.ts
CHANGED
|
@@ -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
|
+
}
|
package/tincan-lib/plugin.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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<
|
|
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) =>
|
|
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 = (
|
|
209
|
-
if (
|
|
210
|
-
|
|
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
|
}
|
package/tincan-lib/server.ts
CHANGED
|
@@ -19,7 +19,12 @@ export const IDLE_TIMEOUT_MS = 30_000;
|
|
|
19
19
|
|
|
20
20
|
export interface ListenOptions {
|
|
21
21
|
path: string;
|
|
22
|
-
|
|
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
|
|
48
|
-
if (
|
|
58
|
+
const reply = (line: string | undefined) => {
|
|
59
|
+
if (answered) return;
|
|
60
|
+
answered = true;
|
|
49
61
|
try {
|
|
50
|
-
|
|
62
|
+
if (socket.writableEnded || socket.destroyed) return;
|
|
63
|
+
if (line === undefined) socket.end();
|
|
64
|
+
else socket.end(`${line}\n`);
|
|
51
65
|
} catch (e) {
|
|
52
|
-
//
|
|
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
|
-
|
|
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
|
package/tincan-lib/types.ts
CHANGED
|
@@ -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.
|
|
12
|
+
export const PLUGIN_VERSION = '0.9.1';
|
|
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_. */
|
package/tincan-lib/wire.ts
CHANGED
|
@@ -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
|
+
}
|