@brutalsystems/birddog-opencode 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # birddog — opencode plugin
2
+
3
+ Makes a live opencode session observable by
4
+ [birddog](https://github.com/BrutalSystems/birddog).
5
+
6
+ A default `opencode` TUI opens no TCP port and writes no port file, lockfile or
7
+ pid file. Nothing outside the process can find it. This plugin closes that gap
8
+ from the inside: it publishes one JSON record per live session to
9
+ `~/.birddog/opencode/`, and birddog reads them.
10
+
11
+ **Without this plugin there is nothing to observe** — an opencode session is
12
+ invisible to birddog even when birddog is installed.
13
+
14
+ ## What it reports
15
+
16
+ | | |
17
+ |---|---|
18
+ | `active` / `idle` | from `session.status` and `session.idle` |
19
+ | `running_tool` | a tool is executing, with its name and start time |
20
+ | `waiting_input` | a permission request is outstanding, with its id |
21
+ | session identity | id, slug, title, working directory, opencode version |
22
+
23
+ opencode is the only runtime where birddog can see a permission request at
24
+ all. For every other provider it reports input-request visibility as
25
+ *unavailable* — which is not the same as reporting that there is none.
26
+
27
+ ## What it does not do
28
+
29
+ It observes. It sends no prompts, approves no requests, changes no tool
30
+ arguments, and never throws.
31
+
32
+ That last one is load-bearing. opencode's `tool.execute.before` hook can
33
+ **block a tool** by throwing — that is how its own env-protection example
34
+ works. Every hook here is guarded, so a failure inside birddog costs birddog
35
+ its visibility and never costs the session its work.
36
+
37
+ ## Install
38
+
39
+ Prefer letting [muster](https://github.com/BrutalSystems/muster) install it at
40
+ launch, by npm specifier:
41
+
42
+ ```toml
43
+ [plugins.birddog]
44
+ npm = "@brutalsystems/birddog-opencode"
45
+ ```
46
+
47
+ ```sh
48
+ muster run opencode --plugin birddog --prompt '...'
49
+ ```
50
+
51
+ A copied file goes stale in silence — the package updates, the copy does not,
52
+ and the old code keeps running. A specifier has no copy to go stale.
53
+
54
+ To install by hand anyway, note that opencode's loader globs
55
+ `{plugin,plugins}/*.{ts,js}` one level deep only, so it is two copies:
56
+
57
+ ```sh
58
+ mkdir -p ~/.config/opencode/plugin
59
+ cp birddog.ts ~/.config/opencode/plugin/
60
+ cp -r birddog-lib ~/.config/opencode/plugin/
61
+ ```
62
+
63
+ `birddog-lib/` is never globbed by the loader, which is the point of the
64
+ name: `~/.config/opencode/plugin/` is shared with every other plugin, so a
65
+ directory called `lib/` would be ambiguous.
66
+
67
+ ## Verify it is loaded
68
+
69
+ ```sh
70
+ ls ~/.birddog/opencode/ # one ses_*.json per live session
71
+ birddog discover # the sessions birddog can now see
72
+ ```
73
+
74
+ If nothing appears, check `~/.birddog/opencode-plugin.log`. The plugin cannot
75
+ print to the terminal: `console.error` would land in the TUI opencode is
76
+ drawing its interface on, and nowhere readable afterwards.
77
+
78
+ Confirm the loaded version matches what is installed — a hand-copied plugin
79
+ lagging its package is the failure the npm specifier exists to prevent:
80
+
81
+ ```sh
82
+ grep plugin_version ~/.birddog/opencode/*.json
83
+ ```
84
+
85
+ ## Requirements
86
+
87
+ - opencode 1.18.31 (verified; other versions untested)
88
+ - Node 22 or later
89
+
90
+ ## Where the records go
91
+
92
+ `~/.birddog/opencode/<session_id>.json`, owner-only, written by rename so a
93
+ reader never sees a half-written record. Set `BIRDDOG_HOME` to move both
94
+ halves somewhere else.
95
+
96
+ Each record carries the writing process's pid and a heartbeat, which is how
97
+ birddog tells a session that has gone quiet from a process that died —
98
+ including when the pid has since been reused by something else.
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,28 @@
1
+ import { join } from 'node:path';
2
+
3
+ /**
4
+ * Where the plugin publishes, and birddog reads.
5
+ *
6
+ * BIRDDOG_HOME is honoured so a test, or an operator running two setups, can
7
+ * point both halves at the same alternative directory.
8
+ */
9
+ export function birddogHome(env: Record<string, string | undefined>, home: string): string {
10
+ return env.BIRDDOG_HOME && env.BIRDDOG_HOME.length > 0 ? env.BIRDDOG_HOME : join(home, '.birddog');
11
+ }
12
+
13
+ /** One record per live opencode session. */
14
+ export function sessionsDir(env: Record<string, string | undefined>, home: string): string {
15
+ return join(birddogHome(env, home), 'opencode');
16
+ }
17
+
18
+ /**
19
+ * Deliberately beside the records, not among them: birddog scans that
20
+ * directory and sweeps it, and a log file dropped in would confuse both.
21
+ */
22
+ export function pluginLogPath(env: Record<string, string | undefined>, home: string): string {
23
+ return join(birddogHome(env, home), 'opencode-plugin.log');
24
+ }
25
+
26
+ export function sessionFile(dir: string, sessionID: string): string {
27
+ return join(dir, `${sessionID}.json`);
28
+ }
@@ -0,0 +1,113 @@
1
+ import { Registry } from './registry.js';
2
+ import { SessionTracker, type ToolEvent } from './state.js';
3
+
4
+ export interface PluginOptions {
5
+ dir: string;
6
+ pid: number;
7
+ pluginVersion: string;
8
+ log: (line: string) => void;
9
+ /** How often to republish so birddog can tell a quiet session from a dead
10
+ * process. Zero disables the timer, which is what tests want. */
11
+ heartbeatMs?: number;
12
+ }
13
+
14
+ export interface PluginHooks {
15
+ event: (arg: { event: unknown }) => Promise<void>;
16
+ 'tool.execute.before': (input: unknown, output: unknown) => Promise<void>;
17
+ 'tool.execute.after': (input: unknown, output: unknown) => Promise<void>;
18
+ stop: () => void;
19
+ }
20
+
21
+ /** defaultHeartbeatMs republishes often enough that a dead process is noticed
22
+ * promptly, rarely enough to cost nothing on a session doing real work. */
23
+ const defaultHeartbeatMs = 15_000;
24
+
25
+ /**
26
+ * startPlugin wires the tracker to the registry.
27
+ *
28
+ * Every hook returned here is guarded. opencode's `tool.execute.before` can
29
+ * block a tool by throwing — that is how its own env-protection example works
30
+ * — so a throw from birddog would turn a monitoring failure into the agent's
31
+ * failure. birddog observes; it does not get a vote on whether work proceeds.
32
+ */
33
+ export function startPlugin(opts: PluginOptions): PluginHooks {
34
+ const tracker = new SessionTracker();
35
+ const registry = new Registry(opts.dir, { pid: opts.pid, pluginVersion: opts.pluginVersion });
36
+
37
+ opts.log(`[birddog] event=started version=${opts.pluginVersion} pid=${opts.pid} dir=${opts.dir}`);
38
+
39
+ let lastReported: string | undefined;
40
+ const publish = async (): Promise<void> => {
41
+ await registry.publish(tracker.all());
42
+ if (registry.lastError && registry.lastError !== lastReported) {
43
+ // Logged once per distinct failure: a broken disk should not fill the
44
+ // log with the same line on every tool call.
45
+ opts.log(`[birddog] event=publish.failed detail=${registry.lastError}`);
46
+ lastReported = registry.lastError;
47
+ } else if (!registry.lastError) {
48
+ lastReported = undefined;
49
+ }
50
+ };
51
+
52
+ // guard is the boundary. Nothing inside the plugin may reach opencode as an
53
+ // exception, including a bug in birddog's own code.
54
+ const guard = async (what: string, fn: () => Promise<void>): Promise<void> => {
55
+ try {
56
+ await fn();
57
+ } catch (err) {
58
+ try {
59
+ opts.log(`[birddog] event=${what}.failed detail=${err instanceof Error ? err.message : String(err)}`);
60
+ } catch {
61
+ // Even logging must not throw into the host.
62
+ }
63
+ }
64
+ };
65
+
66
+ const heartbeat = opts.heartbeatMs ?? defaultHeartbeatMs;
67
+ let timer: ReturnType<typeof setInterval> | undefined;
68
+ if (heartbeat > 0) {
69
+ timer = setInterval(() => void guard('heartbeat', publish), heartbeat);
70
+ // Never keep opencode alive on birddog's account.
71
+ timer.unref?.();
72
+ }
73
+
74
+ return {
75
+ event: (arg) =>
76
+ guard('event', async () => {
77
+ tracker.apply(arg?.event);
78
+ await publish();
79
+ }),
80
+
81
+ // input carries the tool and session; output carries the arguments the
82
+ // tool is about to run with. birddog reads neither's arguments and
83
+ // changes nothing in them.
84
+ 'tool.execute.before': (input) =>
85
+ guard('tool.before', async () => {
86
+ tracker.toolStarted(asToolEvent(input));
87
+ await publish();
88
+ }),
89
+
90
+ 'tool.execute.after': (input) =>
91
+ guard('tool.after', async () => {
92
+ tracker.toolFinished(asToolEvent(input));
93
+ await publish();
94
+ }),
95
+
96
+ stop: () => {
97
+ if (timer) clearInterval(timer);
98
+ },
99
+ };
100
+ }
101
+
102
+ /** asToolEvent reads only the fields needed to attribute the call. The hook's
103
+ * payload shape is undocumented, so anything absent makes the event
104
+ * unattributable rather than attributed to a guess. */
105
+ function asToolEvent(input: unknown): ToolEvent {
106
+ if (typeof input !== 'object' || input === null) return {};
107
+ const o = input as Record<string, unknown>;
108
+ const event: ToolEvent = {};
109
+ if (typeof o.sessionID === 'string') event.sessionID = o.sessionID;
110
+ if (typeof o.tool === 'string') event.tool = o.tool;
111
+ if (typeof o.callID === 'string') event.callID = o.callID;
112
+ return event;
113
+ }
@@ -0,0 +1,109 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { sessionFile } from './paths.js';
5
+ import type { RegistryRecord, SessionSnapshot } from './types.js';
6
+
7
+ export interface RegistryContext {
8
+ pid: number;
9
+ pluginVersion: string;
10
+ now?: () => Date;
11
+ }
12
+
13
+ /**
14
+ * The plugin's half of the contract: one JSON record per live session, which
15
+ * birddog reads and never writes.
16
+ *
17
+ * Nothing here can fail loudly. The plugin runs inside opencode's worker
18
+ * thread, so a problem writing these records must cost birddog its visibility
19
+ * and never cost the session its work — the failure is recorded on lastError
20
+ * for the log instead of thrown at the host.
21
+ */
22
+ export class Registry {
23
+ /** lastError is how a failure is surfaced without throwing. */
24
+ lastError?: string;
25
+
26
+ private now: () => Date;
27
+
28
+ constructor(
29
+ private dir: string,
30
+ private ctx: RegistryContext,
31
+ ) {
32
+ this.now = ctx.now ?? (() => new Date());
33
+ }
34
+
35
+ /** publish writes a record for every live session and removes the records
36
+ * of sessions that have ended. */
37
+ async publish(sessions: SessionSnapshot[]): Promise<void> {
38
+ try {
39
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
40
+ // mkdir respects umask, so the mode is set explicitly.
41
+ await chmod(this.dir, 0o700);
42
+
43
+ const live = new Set<string>();
44
+ for (const session of sessions) {
45
+ live.add(session.session_id);
46
+ await this.write(session);
47
+ }
48
+ await this.sweep(live);
49
+ delete this.lastError;
50
+ } catch (err) {
51
+ this.lastError = err instanceof Error ? err.message : String(err);
52
+ }
53
+ }
54
+
55
+ private async write(session: SessionSnapshot): Promise<void> {
56
+ const record: RegistryRecord = {
57
+ ...session,
58
+ pid: this.ctx.pid,
59
+ plugin_version: this.ctx.pluginVersion,
60
+ // A heartbeat, not decoration: it is how birddog tells a session that
61
+ // has gone quiet from a process that died, including when the pid has
62
+ // since been reused by something else entirely.
63
+ updated_at: `${this.now().toISOString().slice(0, 19)}Z`,
64
+ };
65
+
66
+ const final = sessionFile(this.dir, session.session_id);
67
+ // Temp then rename: a reader can catch a file at any moment, and a
68
+ // half-written record is worse than a slightly stale one. The suffix
69
+ // carries the pid and a random token, because two writes inside one
70
+ // process would otherwise collide on the same name.
71
+ const temp = `${final}.${this.ctx.pid}.${randomBytes(4).toString('hex')}.tmp`;
72
+ await writeFile(temp, JSON.stringify(record, null, 2), { mode: 0o600 });
73
+ await rename(temp, final);
74
+ }
75
+
76
+ /**
77
+ * sweep removes the records of sessions that have ended.
78
+ *
79
+ * Only this process's own records: another opencode process keeps its
80
+ * sessions in the same directory, and sweeping those would blind birddog to
81
+ * every session but ours.
82
+ */
83
+ private async sweep(live: Set<string>): Promise<void> {
84
+ const entries = await readdir(this.dir);
85
+
86
+ for (const name of entries) {
87
+ if (!name.endsWith('.json')) continue;
88
+ const sessionID = name.slice(0, -'.json'.length);
89
+ if (live.has(sessionID)) continue;
90
+
91
+ const path = join(this.dir, name);
92
+ let record: { pid?: unknown };
93
+ try {
94
+ record = JSON.parse(await readFile(path, 'utf8'));
95
+ } catch {
96
+ // Unreadable, so unattributable. Leaving it costs nothing: birddog
97
+ // cannot parse it either, and will not report a session from it.
98
+ continue;
99
+ }
100
+ if (record.pid !== this.ctx.pid) continue;
101
+
102
+ try {
103
+ await unlink(path);
104
+ } catch {
105
+ // Already gone, or not ours to remove.
106
+ }
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,253 @@
1
+ import type { CurrentTool, PendingPermission, SessionSnapshot, SessionState } from './types.js';
2
+
3
+ /**
4
+ * The plugin's view of the sessions in this opencode process.
5
+ *
6
+ * Pure: it holds no handles and performs no I/O, so every transition below is
7
+ * testable without opencode running. It only ever reads what opencode reports.
8
+ * Nothing here can act on a session.
9
+ */
10
+
11
+ /** What opencode hands a tool hook. The shape is not documented, so every
12
+ * field is optional and anything missing means the event is not attributable
13
+ * rather than attributable to a guess. */
14
+ export interface ToolEvent {
15
+ sessionID?: string;
16
+ tool?: string;
17
+ callID?: string;
18
+ }
19
+
20
+ interface Tracked {
21
+ snapshot: SessionSnapshot;
22
+ /** Outstanding tool calls by id. A session is working until the last one
23
+ * finishes: clearing on the first would report a busy session as free. */
24
+ runningTools: Map<string, CurrentTool>;
25
+ }
26
+
27
+ export class SessionTracker {
28
+ private sessions = new Map<string, Tracked>();
29
+
30
+ /** unattributed counts events that named no session birddog knows. It is a
31
+ * diagnostic, not an error: it says coverage is incomplete rather than
32
+ * silently narrowing what is reported. */
33
+ unattributed = 0;
34
+
35
+ constructor(private now: () => Date = () => new Date()) {}
36
+
37
+ /** Every tracked session, oldest id first for stable output. */
38
+ all(): SessionSnapshot[] {
39
+ return [...this.sessions.values()].map((t) => t.snapshot);
40
+ }
41
+
42
+ get(sessionID: string): SessionSnapshot | undefined {
43
+ return this.sessions.get(sessionID)?.snapshot;
44
+ }
45
+
46
+ /** apply folds one opencode event into the view. It never throws: opencode
47
+ * is handing us its event stream, and a plugin that throws into it is a
48
+ * plugin interfering with the session it is supposed to be watching. */
49
+ apply(event: unknown): void {
50
+ const e = asRecord(event);
51
+ if (!e) return;
52
+ const props = asRecord(e.properties) ?? {};
53
+
54
+ switch (e.type) {
55
+ case 'session.created':
56
+ case 'session.updated':
57
+ this.upsert(props.info);
58
+ return;
59
+
60
+ case 'session.deleted': {
61
+ // Read leniently: a delete needs nothing but an id, and refusing a
62
+ // sparse payload leaves a session birddog keeps reporting as live.
63
+ const id = asRecord(props.info)?.id;
64
+ if (typeof id === 'string') this.sessions.delete(id);
65
+ return;
66
+ }
67
+
68
+ case 'session.idle':
69
+ this.setBaseState(props.sessionID, 'idle');
70
+ return;
71
+
72
+ case 'session.status': {
73
+ // Only "idle" is idle. Anything opencode adds later is "not free",
74
+ // which is the safe reading — claiming idle wrongly invites a nudge
75
+ // nobody wanted.
76
+ const type = asRecord(props.status)?.type;
77
+ this.setBaseState(props.sessionID, type === 'idle' ? 'idle' : 'active');
78
+ return;
79
+ }
80
+
81
+ case 'permission.asked':
82
+ this.permissionAsked(props.sessionID, asRecord(props.permission)?.id);
83
+ return;
84
+
85
+ case 'permission.replied':
86
+ this.permissionReplied(props.sessionID, asRecord(props.permission)?.id);
87
+ return;
88
+
89
+ default:
90
+ return;
91
+ }
92
+ }
93
+
94
+ /** toolStarted records a tool beginning. Called from tool.execute.before,
95
+ * which must never throw — see the plugin entry point. */
96
+ toolStarted(event: ToolEvent): void {
97
+ const tracked = this.resolve(event.sessionID);
98
+ if (!tracked) return;
99
+
100
+ const call = event.callID ?? `${event.tool ?? 'tool'}-${tracked.runningTools.size}`;
101
+ tracked.runningTools.set(call, {
102
+ name: event.tool ?? 'unknown',
103
+ started_at: this.stamp(),
104
+ });
105
+ this.touch(tracked);
106
+ }
107
+
108
+ /** toolFinished records a tool completing. A completion with no matching
109
+ * start is dropped rather than decremented, so a stray one cannot leave a
110
+ * session reporting work forever. */
111
+ toolFinished(event: ToolEvent): void {
112
+ const tracked = this.resolve(event.sessionID);
113
+ if (!tracked) return;
114
+
115
+ if (event.callID !== undefined) {
116
+ tracked.runningTools.delete(event.callID);
117
+ } else {
118
+ // No call id: clear one matching entry by name, if there is one.
119
+ for (const [id, tool] of tracked.runningTools) {
120
+ if (tool.name === event.tool) {
121
+ tracked.runningTools.delete(id);
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ this.touch(tracked);
127
+ }
128
+
129
+ private upsert(raw: unknown): void {
130
+ const info = asRecord(raw);
131
+ if (!info) return;
132
+ const { id, slug, title, directory, version } = info;
133
+ if (
134
+ typeof id !== 'string' ||
135
+ typeof slug !== 'string' ||
136
+ typeof title !== 'string' ||
137
+ typeof directory !== 'string' ||
138
+ typeof version !== 'string'
139
+ ) {
140
+ // Not enough to describe a session. Reporting a partial one would put
141
+ // a target in birddog's listing that nobody could identify.
142
+ return;
143
+ }
144
+
145
+ const existing = this.sessions.get(id);
146
+ if (existing) {
147
+ Object.assign(existing.snapshot, { slug, title, directory, opencode_version: version });
148
+ this.touch(existing);
149
+ return;
150
+ }
151
+
152
+ this.sessions.set(id, {
153
+ snapshot: {
154
+ session_id: id,
155
+ slug,
156
+ title,
157
+ directory,
158
+ opencode_version: version,
159
+ state: 'active',
160
+ last_activity_at: this.stamp(),
161
+ },
162
+ runningTools: new Map(),
163
+ });
164
+ }
165
+
166
+ private permissionAsked(sessionID: unknown, permissionID: unknown): void {
167
+ const tracked = this.resolve(sessionID);
168
+ if (!tracked) return;
169
+
170
+ const pending: PendingPermission = {
171
+ id: typeof permissionID === 'string' ? permissionID : 'unknown',
172
+ asked_at: this.stamp(),
173
+ };
174
+ tracked.snapshot.pending_permission = pending;
175
+ this.touch(tracked);
176
+ }
177
+
178
+ private permissionReplied(sessionID: unknown, permissionID: unknown): void {
179
+ const tracked = this.resolve(sessionID);
180
+ if (!tracked) return;
181
+
182
+ const pending = tracked.snapshot.pending_permission;
183
+ // A reply naming a different request must not clear the one still
184
+ // waiting, or a session stays blocked while reporting that it is not.
185
+ if (pending && typeof permissionID === 'string' && pending.id !== permissionID) return;
186
+
187
+ delete tracked.snapshot.pending_permission;
188
+ this.touch(tracked);
189
+ }
190
+
191
+ private setBaseState(sessionID: unknown, state: SessionState): void {
192
+ const tracked = this.resolve(sessionID);
193
+ if (!tracked) return;
194
+ tracked.snapshot.state = state;
195
+ this.touch(tracked);
196
+ }
197
+
198
+ /** resolve finds the session an event belongs to, counting the ones that
199
+ * cannot be attributed instead of guessing at the most recent. */
200
+ private resolve(sessionID: unknown): Tracked | undefined {
201
+ if (typeof sessionID !== 'string') {
202
+ this.unattributed++;
203
+ return undefined;
204
+ }
205
+ const tracked = this.sessions.get(sessionID);
206
+ if (!tracked) this.unattributed++;
207
+ return tracked;
208
+ }
209
+
210
+ /** touch recomputes the reported state and the activity stamp.
211
+ *
212
+ * Precedence: waiting on a human outranks everything — it is what an
213
+ * orchestrator most needs to hear, and a tool running underneath does not
214
+ * make the session unblocked. A running tool outranks the base state,
215
+ * because work in progress is not silence. */
216
+ private touch(tracked: Tracked): void {
217
+ const { snapshot, runningTools } = tracked;
218
+
219
+ if (snapshot.pending_permission) {
220
+ snapshot.state = 'waiting_input';
221
+ delete snapshot.current_tool;
222
+ } else if (runningTools.size > 0) {
223
+ snapshot.state = 'running_tool';
224
+ snapshot.current_tool = oldest(runningTools);
225
+ } else {
226
+ delete snapshot.current_tool;
227
+ if (snapshot.state === 'waiting_input' || snapshot.state === 'running_tool') {
228
+ // The thing that explained the state is over; the session is working
229
+ // again until opencode says otherwise.
230
+ snapshot.state = 'active';
231
+ }
232
+ }
233
+ snapshot.last_activity_at = this.stamp();
234
+ }
235
+
236
+ private stamp(): string {
237
+ return `${this.now().toISOString().slice(0, 19)}Z`;
238
+ }
239
+ }
240
+
241
+ /** oldest returns the longest-running tool, which is the one worth naming. */
242
+ function oldest(tools: Map<string, CurrentTool>): CurrentTool {
243
+ let chosen: CurrentTool | undefined;
244
+ for (const tool of tools.values()) {
245
+ if (!chosen || tool.started_at < chosen.started_at) chosen = tool;
246
+ }
247
+ // The map is non-empty at every call site.
248
+ return chosen as CurrentTool;
249
+ }
250
+
251
+ function asRecord(v: unknown): Record<string, unknown> | undefined {
252
+ return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : undefined;
253
+ }
@@ -0,0 +1,36 @@
1
+ /** Runtime states, matching birddog's own vocabulary so the Go adapter that
2
+ * reads these records needs no translation table of its own. */
3
+ export type SessionState = 'active' | 'idle' | 'waiting_input' | 'running_tool';
4
+
5
+ /** What is known about one outstanding permission request. */
6
+ export interface PendingPermission {
7
+ id: string;
8
+ asked_at: string;
9
+ }
10
+
11
+ /** The tool a session is currently running. */
12
+ export interface CurrentTool {
13
+ name: string;
14
+ started_at: string;
15
+ }
16
+
17
+ /** One session, as the plugin sees it. */
18
+ export interface SessionSnapshot {
19
+ session_id: string;
20
+ slug: string;
21
+ title: string;
22
+ directory: string;
23
+ opencode_version: string;
24
+
25
+ state: SessionState;
26
+ pending_permission?: PendingPermission;
27
+ current_tool?: CurrentTool;
28
+ last_activity_at: string;
29
+ }
30
+
31
+ /** A record on disk: a snapshot plus who wrote it. */
32
+ export interface RegistryRecord extends SessionSnapshot {
33
+ pid: number;
34
+ plugin_version: string;
35
+ updated_at: string;
36
+ }
package/birddog.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * birddog — opencode plugin.
3
+ *
4
+ * Publishes this opencode process's session state to `~/.birddog/opencode/`,
5
+ * where birddog reads it. A default opencode TUI opens no port and writes no
6
+ * pid file, so nothing outside the process can find it; this plugin is the
7
+ * only way an opencode session is observable at all.
8
+ *
9
+ * It is read-only with respect to the session. It reports what opencode tells
10
+ * it and never acts: no prompts, no approvals, no changes to a tool's
11
+ * arguments, and no exception that could block one.
12
+ *
13
+ * WARNING: opencode's loader invokes EVERY exported function in this file as
14
+ * a plugin, and does not descend into subdirectories. Export exactly one
15
+ * thing, and never a `default`. All logic lives in ./birddog-lib/.
16
+ */
17
+ import { appendFileSync, chmodSync, mkdirSync, statSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { dirname } from 'node:path';
20
+ import { pluginLogPath, sessionsDir } from './birddog-lib/paths.js';
21
+ import { startPlugin } from './birddog-lib/plugin.js';
22
+
23
+ /** Rotated past this: small enough to stay cheap to read, big enough to hold
24
+ * a long session's diagnostics. */
25
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
26
+
27
+ /** version is kept in step with package.json by scripts/sync-version.mjs, and
28
+ * a test asserts they agree. */
29
+ const VERSION = '0.1.0';
30
+
31
+ export const Birddog = async () => {
32
+ const logPath = pluginLogPath(process.env, homedir());
33
+
34
+ // console.error would land in the TUI opencode is drawing its interface on,
35
+ // and nowhere an operator can read afterwards. A file is the only way a
36
+ // failure is visible once the session ends.
37
+ //
38
+ // This runs on opencode's worker thread, so the steady state is one syscall
39
+ // per line: the mkdir, the chmod and the size check happen on the first
40
+ // write only. The sink must not throw either — a logging failure must never
41
+ // reach the host.
42
+ let logBytes = -1;
43
+ let tightened = false;
44
+ const log = (line: string) => {
45
+ try {
46
+ const data = `${line}\n`;
47
+ if (logBytes < 0) {
48
+ mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
49
+ try {
50
+ logBytes = statSync(logPath).size;
51
+ } catch {
52
+ logBytes = 0;
53
+ }
54
+ }
55
+ if (logBytes >= MAX_LOG_BYTES) {
56
+ // Truncate rather than rotate: these are diagnostics, not a record
57
+ // anyone is entitled to keep.
58
+ logBytes = 0;
59
+ }
60
+ appendFileSync(logPath, data, { mode: 0o600 });
61
+ logBytes += Buffer.byteLength(data, 'utf8');
62
+ if (!tightened) {
63
+ // `mode` on appendFileSync only applies when the file is created, so
64
+ // a pre-existing world-readable log is tightened here too.
65
+ chmodSync(logPath, 0o600);
66
+ tightened = true;
67
+ }
68
+ } catch {
69
+ // Nowhere left to report it. Losing a log line is not worth disturbing
70
+ // the session over.
71
+ }
72
+ };
73
+
74
+ return startPlugin({
75
+ dir: sessionsDir(process.env, homedir()),
76
+ pid: process.pid,
77
+ pluginVersion: VERSION,
78
+ log,
79
+ });
80
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@brutalsystems/birddog-opencode",
3
+ "version": "0.0.0",
4
+ "description": "opencode plugin for birddog — publishes a session's state so birddog can observe it from outside. Read-only: it reports, and never acts on the session.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "birddog",
9
+ "agents",
10
+ "monitoring",
11
+ "observability"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Mike Williams",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/BrutalSystems/birddog.git",
18
+ "directory": "plugins/opencode"
19
+ },
20
+ "homepage": "https://github.com/BrutalSystems/birddog#readme",
21
+ "type": "module",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "main": "./birddog.ts",
29
+ "exports": {
30
+ ".": "./birddog.ts",
31
+ "./server": "./birddog.ts"
32
+ },
33
+ "files": [
34
+ "birddog.ts",
35
+ "birddog-lib",
36
+ "README.md",
37
+ "LICENSE"
38
+ ]
39
+ }