@brutalsystems/birddog-opencode 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,17 @@ muster run opencode --plugin birddog --prompt '...'
49
49
  ```
50
50
 
51
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.
52
+ and the old code keeps running.
53
+
54
+ A specifier is better but not immune: opencode resolves it once into
55
+ `~/.cache/opencode/packages/@brutalsystems/birddog-opencode@latest` and does
56
+ not revisit it, so a new release is not picked up until that directory is
57
+ removed. Check what is actually loaded rather than assuming:
58
+
59
+ ```sh
60
+ grep plugin_version ~/.birddog/opencode/*.json
61
+ rm -rf ~/.cache/opencode/packages/@brutalsystems/birddog-opencode@latest
62
+ ```
53
63
 
54
64
  To install by hand anyway, note that opencode's loader globs
55
65
  `{plugin,plugins}/*.{ts,js}` one level deep only, so it is two copies:
@@ -1,11 +1,25 @@
1
1
  import { Registry } from './registry.js';
2
2
  import { SessionTracker, type ToolEvent } from './state.js';
3
3
 
4
+ /** Transport is the bit of opencode's client birddog needs: one read. */
5
+ export interface Transport {
6
+ get(args: { url: string }): Promise<{ data?: unknown; response?: { status?: number } }>;
7
+ }
8
+
4
9
  export interface PluginOptions {
5
10
  dir: string;
6
11
  pid: number;
7
12
  pluginVersion: string;
8
13
  log: (line: string) => void;
14
+
15
+ /**
16
+ * transport reads opencode's own session list at startup, so a session that
17
+ * already existed when the plugin loaded is still observed.
18
+ */
19
+ transport?: Transport;
20
+
21
+ /** directory this process serves; adoption is limited to it. */
22
+ directory?: string;
9
23
  /** How often to republish so birddog can tell a quiet session from a dead
10
24
  * process. Zero disables the timer, which is what tests want. */
11
25
  heartbeatMs?: number;
@@ -63,6 +77,23 @@ export function startPlugin(opts: PluginOptions): PluginHooks {
63
77
  }
64
78
  };
65
79
 
80
+ // Resolve sessions that events name but the tracker does not know. This is
81
+ // how a session created before the plugin loaded — every muster launch —
82
+ // becomes visible at all. Driven by events rather than by listing, so
83
+ // opencode's session history is never pulled in.
84
+ const resolveUnknown = async (): Promise<void> => {
85
+ if (!opts.transport) return;
86
+ for (const id of tracker.takeUnknown()) {
87
+ const res = await opts.transport.get({ url: `/session/${id}` });
88
+ if ((res.response?.status ?? 0) !== 200 || typeof res.data !== 'object' || res.data == null) {
89
+ opts.log(`[birddog] event=resolve.failed session=${id} status=${res.response?.status ?? 0}`);
90
+ continue;
91
+ }
92
+ tracker.adoptOne(res.data);
93
+ opts.log(`[birddog] event=adopted session=${id}`);
94
+ }
95
+ };
96
+
66
97
  const heartbeat = opts.heartbeatMs ?? defaultHeartbeatMs;
67
98
  let timer: ReturnType<typeof setInterval> | undefined;
68
99
  if (heartbeat > 0) {
@@ -75,6 +106,7 @@ export function startPlugin(opts: PluginOptions): PluginHooks {
75
106
  event: (arg) =>
76
107
  guard('event', async () => {
77
108
  tracker.apply(arg?.event);
109
+ await resolveUnknown();
78
110
  await publish();
79
111
  }),
80
112
 
@@ -84,6 +116,7 @@ export function startPlugin(opts: PluginOptions): PluginHooks {
84
116
  'tool.execute.before': (input) =>
85
117
  guard('tool.before', async () => {
86
118
  tracker.toolStarted(asToolEvent(input));
119
+ await resolveUnknown();
87
120
  await publish();
88
121
  }),
89
122
 
@@ -25,6 +25,14 @@ export class Registry {
25
25
 
26
26
  private now: () => Date;
27
27
 
28
+ /**
29
+ * Identifies this plugin instance. opencode instantiates the plugin twice
30
+ * in one process, so the pid is shared and cannot say who wrote a record —
31
+ * and sweeping by pid made the second instance delete the first's records
32
+ * for sessions that were still running.
33
+ */
34
+ private readonly instanceId = `inst-${randomBytes(6).toString('hex')}`;
35
+
28
36
  constructor(
29
37
  private dir: string,
30
38
  private ctx: RegistryContext,
@@ -56,6 +64,7 @@ export class Registry {
56
64
  const record: RegistryRecord = {
57
65
  ...session,
58
66
  pid: this.ctx.pid,
67
+ instance_id: this.instanceId,
59
68
  plugin_version: this.ctx.pluginVersion,
60
69
  // A heartbeat, not decoration: it is how birddog tells a session that
61
70
  // has gone quiet from a process that died, including when the pid has
@@ -76,9 +85,10 @@ export class Registry {
76
85
  /**
77
86
  * sweep removes the records of sessions that have ended.
78
87
  *
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.
88
+ * Only this instance's own records. Another opencode process keeps its
89
+ * sessions in the same directory, and so does the second plugin instance in
90
+ * this one — sweeping either would blind birddog to sessions that are
91
+ * running perfectly well.
82
92
  */
83
93
  private async sweep(live: Set<string>): Promise<void> {
84
94
  const entries = await readdir(this.dir);
@@ -89,7 +99,7 @@ export class Registry {
89
99
  if (live.has(sessionID)) continue;
90
100
 
91
101
  const path = join(this.dir, name);
92
- let record: { pid?: unknown };
102
+ let record: { instance_id?: unknown };
93
103
  try {
94
104
  record = JSON.parse(await readFile(path, 'utf8'));
95
105
  } catch {
@@ -97,7 +107,7 @@ export class Registry {
97
107
  // cannot parse it either, and will not report a session from it.
98
108
  continue;
99
109
  }
100
- if (record.pid !== this.ctx.pid) continue;
110
+ if (record.instance_id !== this.instanceId) continue;
101
111
 
102
112
  try {
103
113
  await unlink(path);
@@ -32,6 +32,9 @@ export class SessionTracker {
32
32
  * silently narrowing what is reported. */
33
33
  unattributed = 0;
34
34
 
35
+ /** Sessions named by events that are not tracked yet. */
36
+ private unknown = new Set<string>();
37
+
35
38
  constructor(private now: () => Date = () => new Date()) {}
36
39
 
37
40
  /** Every tracked session, oldest id first for stable output. */
@@ -43,6 +46,33 @@ export class SessionTracker {
43
46
  return this.sessions.get(sessionID)?.snapshot;
44
47
  }
45
48
 
49
+ /**
50
+ * adoptOne records a session the tracker never saw begin.
51
+ *
52
+ * Driven by takeUnknown, so only sessions this process actually emits
53
+ * events for are ever adopted. Fetching opencode's whole session list
54
+ * instead would pull in its entire history — a hundred sessions that ended
55
+ * days ago — and republish them under this process's pid with a fresh
56
+ * heartbeat, which is precisely how birddog decides something is live.
57
+ */
58
+ adoptOne(session: unknown): void {
59
+ const info = asRecord(session);
60
+ if (!info || typeof info.id !== 'string') return;
61
+ if (this.sessions.has(info.id)) return;
62
+ this.upsert(info);
63
+ }
64
+
65
+ /**
66
+ * takeUnknown returns the sessions named by events that this tracker does
67
+ * not know, and forgets them. The caller is expected to go and resolve
68
+ * them; asking again must not repeat work already in flight.
69
+ */
70
+ takeUnknown(): string[] {
71
+ const ids = [...this.unknown];
72
+ this.unknown.clear();
73
+ return ids;
74
+ }
75
+
46
76
  /** apply folds one opencode event into the view. It never throws: opencode
47
77
  * is handing us its event stream, and a plugin that throws into it is a
48
78
  * plugin interfering with the session it is supposed to be watching. */
@@ -78,12 +108,16 @@ export class SessionTracker {
78
108
  return;
79
109
  }
80
110
 
111
+ // The shape below was captured from opencode 1.18.31 rather than
112
+ // inferred. `permission` is the kind being asked for ("bash", "edit"),
113
+ // a plain string — not an object carrying the id. The id is top level
114
+ // on asked, and comes back as `requestID` on replied.
81
115
  case 'permission.asked':
82
- this.permissionAsked(props.sessionID, asRecord(props.permission)?.id);
116
+ this.permissionAsked(props);
83
117
  return;
84
118
 
85
119
  case 'permission.replied':
86
- this.permissionReplied(props.sessionID, asRecord(props.permission)?.id);
120
+ this.permissionReplied(props.sessionID, props.requestID);
87
121
  return;
88
122
 
89
123
  default:
@@ -163,14 +197,22 @@ export class SessionTracker {
163
197
  });
164
198
  }
165
199
 
166
- private permissionAsked(sessionID: unknown, permissionID: unknown): void {
167
- const tracked = this.resolve(sessionID);
200
+ private permissionAsked(props: Record<string, unknown>): void {
201
+ const tracked = this.resolve(props.sessionID);
168
202
  if (!tracked) return;
169
203
 
170
204
  const pending: PendingPermission = {
171
- id: typeof permissionID === 'string' ? permissionID : 'unknown',
205
+ id: typeof props.id === 'string' ? props.id : 'unknown',
172
206
  asked_at: this.stamp(),
173
207
  };
208
+ if (typeof props.permission === 'string') {
209
+ pending.type = props.permission;
210
+ }
211
+ const detail = requestDetail(asRecord(props.metadata));
212
+ if (detail !== undefined) {
213
+ pending.detail = detail;
214
+ }
215
+
174
216
  tracked.snapshot.pending_permission = pending;
175
217
  this.touch(tracked);
176
218
  }
@@ -191,6 +233,18 @@ export class SessionTracker {
191
233
  private setBaseState(sessionID: unknown, state: SessionState): void {
192
234
  const tracked = this.resolve(sessionID);
193
235
  if (!tracked) return;
236
+
237
+ if (state === 'idle') {
238
+ // An idle session is not running a tool. Without this, a completion
239
+ // that never arrives leaves the session reporting running_tool
240
+ // forever — which happens because opencode instantiates the plugin
241
+ // twice and the tool hooks do not reliably reach both instances.
242
+ //
243
+ // A pending permission is not cleared here: it outranks idle, and is
244
+ // the usual reason a session sits idle with work still to do.
245
+ tracked.runningTools.clear();
246
+ }
247
+
194
248
  tracked.snapshot.state = state;
195
249
  this.touch(tracked);
196
250
  }
@@ -203,7 +257,11 @@ export class SessionTracker {
203
257
  return undefined;
204
258
  }
205
259
  const tracked = this.sessions.get(sessionID);
206
- if (!tracked) this.unattributed++;
260
+ if (!tracked) {
261
+ this.unattributed++;
262
+ // Worth resolving: an event naming it means this process is serving it.
263
+ this.unknown.add(sessionID);
264
+ }
207
265
  return tracked;
208
266
  }
209
267
 
@@ -248,6 +306,37 @@ function oldest(tools: Map<string, CurrentTool>): CurrentTool {
248
306
  return chosen as CurrentTool;
249
307
  }
250
308
 
309
+ /**
310
+ * requestDetail summarises what a request is asking to do, so an alert can
311
+ * say "waiting to run rm test.tst" rather than only "waiting on approval".
312
+ *
313
+ * Deliberately narrow: metadata also carries the full diff of an edit, which
314
+ * can be thousands of lines. birddog records evidence, not payloads.
315
+ */
316
+ const MAX_DETAIL = 200;
317
+
318
+ function requestDetail(metadata: Record<string, unknown> | undefined): string | undefined {
319
+ if (!metadata) return undefined;
320
+ const value = metadata.command ?? metadata.filepath;
321
+ if (typeof value !== 'string' || value === '') return undefined;
322
+ return value.length > MAX_DETAIL ? `${value.slice(0, MAX_DETAIL)}…` : value;
323
+ }
324
+
325
+ /**
326
+ * sameDirectory compares two paths allowing for macOS reporting /tmp as
327
+ * /private/tmp, which would otherwise make every launch under /tmp adopt
328
+ * nothing at all.
329
+ */
330
+ function sameDirectory(a: unknown, b: string): boolean {
331
+ if (typeof a !== 'string') return false;
332
+ return normalise(a) === normalise(b);
333
+ }
334
+
335
+ function normalise(p: string): string {
336
+ const trimmed = p.replace(/\/+$/, '');
337
+ return trimmed.startsWith('/private/') ? trimmed.slice('/private'.length) : trimmed;
338
+ }
339
+
251
340
  function asRecord(v: unknown): Record<string, unknown> | undefined {
252
341
  return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : undefined;
253
342
  }
@@ -6,6 +6,17 @@ export type SessionState = 'active' | 'idle' | 'waiting_input' | 'running_tool';
6
6
  export interface PendingPermission {
7
7
  id: string;
8
8
  asked_at: string;
9
+
10
+ /** What kind of approval is being asked for: "bash", "edit", and so on. */
11
+ type?: string;
12
+
13
+ /**
14
+ * What it is asking to do — the command, or the file being edited.
15
+ *
16
+ * Bounded on purpose: an edit request carries a full diff, and birddog
17
+ * records evidence rather than payloads.
18
+ */
19
+ detail?: string;
9
20
  }
10
21
 
11
22
  /** The tool a session is currently running. */
@@ -31,6 +42,14 @@ export interface SessionSnapshot {
31
42
  /** A record on disk: a snapshot plus who wrote it. */
32
43
  export interface RegistryRecord extends SessionSnapshot {
33
44
  pid: number;
45
+
46
+ /**
47
+ * Which plugin instance wrote this. opencode instantiates the plugin twice
48
+ * in one process, so a pid does not identify the writer — and sweeping by
49
+ * pid makes one instance delete the other's live records.
50
+ */
51
+ instance_id: string;
52
+
34
53
  plugin_version: string;
35
54
  updated_at: string;
36
55
  }
package/birddog.ts CHANGED
@@ -26,9 +26,9 @@ const MAX_LOG_BYTES = 2 * 1024 * 1024;
26
26
 
27
27
  /** version is kept in step with package.json by scripts/sync-version.mjs, and
28
28
  * a test asserts they agree. */
29
- const VERSION = '0.1.0';
29
+ const VERSION = '0.1.2';
30
30
 
31
- export const Birddog = async () => {
31
+ export const Birddog = async (input?: { client?: { _client?: unknown } }) => {
32
32
  const logPath = pluginLogPath(process.env, homedir());
33
33
 
34
34
  // console.error would land in the TUI opencode is drawing its interface on,
@@ -71,10 +71,23 @@ export const Birddog = async () => {
71
71
  }
72
72
  };
73
73
 
74
+ // opencode hands the plugin its own client. The transport underneath is
75
+ // what lets birddog ask which sessions already exist, rather than only
76
+ // learning about ones that begin after it loads.
77
+ const transport = input?.client?._client;
78
+ const usable =
79
+ transport && typeof (transport as { get?: unknown }).get === 'function'
80
+ ? (transport as { get(args: { url: string }): Promise<{ data?: unknown; response?: { status?: number } }> })
81
+ : undefined;
82
+ if (!usable) {
83
+ log('[birddog] event=transport.missing detail=cannot read existing sessions');
84
+ }
85
+
74
86
  return startPlugin({
75
87
  dir: sessionsDir(process.env, homedir()),
76
88
  pid: process.pid,
77
89
  pluginVersion: VERSION,
78
90
  log,
91
+ ...(usable ? { transport: usable } : {}),
79
92
  });
80
93
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/birddog-opencode",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "keywords": [
6
6
  "opencode",