@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 +21 -0
- package/README.md +136 -0
- package/package.json +33 -0
- package/tincan-lib/caller.ts +49 -0
- package/tincan-lib/delivery.ts +98 -0
- package/tincan-lib/events.ts +63 -0
- package/tincan-lib/log.ts +74 -0
- package/tincan-lib/paths.ts +40 -0
- package/tincan-lib/plugin.ts +239 -0
- package/tincan-lib/registry.ts +161 -0
- package/tincan-lib/server.ts +173 -0
- package/tincan-lib/types.ts +70 -0
- package/tincan-lib/wire.ts +73 -0
- package/tincan.ts +88 -0
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
|
+
};
|