@brutalsystems/birddog-opencode 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/birddog-lib/plugin.ts +33 -0
- package/birddog-lib/registry.ts +15 -5
- package/birddog-lib/state.ts +95 -6
- package/birddog-lib/types.ts +19 -0
- package/birddog.ts +15 -2
- package/package.json +1 -1
package/birddog-lib/plugin.ts
CHANGED
|
@@ -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
|
|
package/birddog-lib/registry.ts
CHANGED
|
@@ -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
|
|
80
|
-
* sessions in the same directory, and
|
|
81
|
-
*
|
|
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: {
|
|
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.
|
|
110
|
+
if (record.instance_id !== this.instanceId) continue;
|
|
101
111
|
|
|
102
112
|
try {
|
|
103
113
|
await unlink(path);
|
package/birddog-lib/state.ts
CHANGED
|
@@ -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
|
|
116
|
+
this.permissionAsked(props);
|
|
83
117
|
return;
|
|
84
118
|
|
|
85
119
|
case 'permission.replied':
|
|
86
|
-
this.permissionReplied(props.sessionID,
|
|
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(
|
|
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
|
|
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)
|
|
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
|
}
|
package/birddog-lib/types.ts
CHANGED
|
@@ -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.
|
|
29
|
+
const VERSION = '0.1.1';
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|