@fdcn/sourcefed 0.3.1 → 0.3.4

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.
@@ -14,7 +14,7 @@ sourcefed monitor start --id ID [--target-kind KIND] [--target-id ID]
14
14
  sourcefed monitor remove --id ID --yes [--target-kind KIND] [--target-id ID]
15
15
  sourcefed monitor events [--target-kind KIND] [--target-id ID]
16
16
  sourcefed monitor follow [--target-kind KIND] [--target-id ID]
17
- sourcefed monitor ack --event-id ID[,ID...] [--target-kind KIND] [--target-id ID]
17
+ sourcefed monitor ack --event-id ID [--event-id ID...] [--target-kind KIND] [--target-id ID]
18
18
  sourcefed monitor sources
19
19
  sourcefed skills [list] List bundled skills
20
20
  sourcefed skills get <name> [--full] Output skill content (--full adds references)
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  decodeTarget,
3
3
  encodeTarget
4
- } from "./chunk-QPDTUNFA.js";
4
+ } from "./chunk-X3BZSHKZ.js";
5
5
  import {
6
6
  __export
7
7
  } from "./chunk-MLKGABMK.js";
package/dist/opencode.js CHANGED
@@ -1,10 +1,13 @@
1
+ import {
2
+ eventToText
3
+ } from "./chunk-BNLZQKEB.js";
1
4
  import {
2
5
  SOURCE_TYPES,
3
6
  connectDaemonClient,
4
7
  daemonCommand,
5
8
  daemonEnvironment,
6
9
  spawnLocalDaemon
7
- } from "./chunk-QPDTUNFA.js";
10
+ } from "./chunk-X3BZSHKZ.js";
8
11
  import "./chunk-MLKGABMK.js";
9
12
 
10
13
  // packages/sourcefed/src/server.ts
@@ -18,10 +21,41 @@ import { Plugin } from "@opencode/plugin";
18
21
  // adapters/opencode/bridge.ts
19
22
  import { existsSync } from "node:fs";
20
23
  import { fileURLToPath } from "node:url";
24
+ import { Rpc } from "@opencode/plugin";
25
+ var TARGET_KIND = "opencode-session";
26
+ var GATE_TIMEOUT_MS = 3e3;
27
+ var ChauffeurGate = Rpc.define({
28
+ id: "chauffeur",
29
+ methods: {
30
+ gate: {
31
+ input: {
32
+ type: "object",
33
+ properties: {
34
+ sessionID: { type: "string" },
35
+ // The monitor that produced the event, so the gate knows what it watches.
36
+ monitorID: { type: "string" },
37
+ source: { type: "string" },
38
+ kind: { type: "string" },
39
+ summary: { type: "string" },
40
+ body: { type: "string" },
41
+ actionable: { type: "boolean" }
42
+ },
43
+ required: ["sessionID", "source", "kind", "summary", "actionable"]
44
+ },
45
+ output: {
46
+ type: "object",
47
+ properties: { deliver: { type: "boolean" } },
48
+ required: ["deliver"]
49
+ }
50
+ }
51
+ },
52
+ events: {}
53
+ });
21
54
  var activeBridge;
22
55
  var OpenCodeBridge = class {
23
- constructor(session) {
56
+ constructor(session, rpc) {
24
57
  this.session = session;
58
+ this.rpc = rpc;
25
59
  }
26
60
  daemon;
27
61
  listeners = /* @__PURE__ */ new Map();
@@ -32,12 +66,21 @@ var OpenCodeBridge = class {
32
66
  }
33
67
  async ensureTarget(sessionID) {
34
68
  await this.ensureDaemon();
35
- if (this.listeners.has(sessionID)) return;
36
- const target = this.target(sessionID);
37
- const listener = await this.daemon.subscribe(target, async (events) => {
69
+ const existing = this.listeners.get(sessionID);
70
+ if (existing) {
71
+ await existing;
72
+ return;
73
+ }
74
+ const pending = this.daemon.subscribe(this.target(sessionID), async (events) => {
38
75
  await this.routeEvents(events);
39
76
  });
40
- this.listeners.set(sessionID, listener);
77
+ this.listeners.set(sessionID, pending);
78
+ try {
79
+ await pending;
80
+ } catch (error) {
81
+ this.listeners.delete(sessionID);
82
+ throw error;
83
+ }
41
84
  }
42
85
  async callTool(name, arguments_, sessionID) {
43
86
  await this.ensureDaemon();
@@ -48,7 +91,7 @@ var OpenCodeBridge = class {
48
91
  return this.daemon.request(name.replace(/_/g, "."), { ...arguments_, target: this.target(sessionID) });
49
92
  }
50
93
  async close() {
51
- for (const listener of this.listeners.values()) await listener.close();
94
+ for (const pending of this.listeners.values()) await pending.then((listener) => listener.close(), () => void 0);
52
95
  this.listeners.clear();
53
96
  await this.daemon?.close();
54
97
  this.daemon = void 0;
@@ -58,42 +101,92 @@ var OpenCodeBridge = class {
58
101
  if (Date.now() - this.lastAttemptAt < 15e3) return;
59
102
  this.lastAttemptAt = Date.now();
60
103
  try {
61
- const url = process.env.SOURCEFED_DAEMON_URL;
62
- if (url) {
63
- this.daemon = await connectDaemonClient({ name: "sourcefed-opencode", url });
64
- return;
65
- }
66
- const local = daemonCommand(cliEntry());
67
- const spawned = await spawnLocalDaemon({
68
- command: local.command,
69
- args: local.args,
70
- env: daemonEnvironment()
71
- });
72
- this.daemon = await connectDaemonClient({ name: "sourcefed-opencode", url: spawned.url });
104
+ this.daemon = await connectDaemon();
105
+ void this.resubscribe();
73
106
  } catch (error) {
74
107
  this.lastError = error instanceof Error ? error.message : String(error);
75
108
  console.error(`[sourcefed] daemon unavailable: ${this.lastError}`);
76
109
  }
77
110
  }
78
- // V2 `session.prompt` has no model field — the model is a session property and
79
- // prompting does not reset it, so no model-reuse dance is needed. Actionable
80
- // events prompt the agent; non-actionable ones inject a synthetic message that
81
- // lands in the transcript without triggering a reply.
111
+ /**
112
+ * Monitors outlive this process, so after a restart nothing listens for their
113
+ * sessions until something subscribes. Subscribe every session that has an
114
+ * enabled monitor and still exists here; a deleted session is skipped, since
115
+ * delivering to it would fail and retry forever.
116
+ */
117
+ async resubscribe() {
118
+ try {
119
+ const { targets } = await this.daemon.request("monitor.targets", { kind: TARGET_KIND });
120
+ await Promise.all(targets.map((target) => this.resubscribeTarget(target.id)));
121
+ } catch (error) {
122
+ console.error(`[sourcefed] resubscribe failed: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ }
125
+ async resubscribeTarget(sessionID) {
126
+ const exists = await this.session.get({ sessionID }).then(() => true, () => false);
127
+ if (!exists) return;
128
+ await this.ensureTarget(sessionID).catch((error) => {
129
+ console.error(`[sourcefed] subscribe ${sessionID} failed: ${error instanceof Error ? error.message : String(error)}`);
130
+ });
131
+ }
132
+ // Every event arrives as a synthetic message, never as a user prompt, so it is
133
+ // not mistaken for the user's own words. Actionable events resume an idle
134
+ // agent; the rest wait in the transcript for the next turn.
82
135
  async routeEvents(events) {
83
136
  for (const queued of events) {
84
137
  const sessionID = queued.target.id;
85
- const text = eventText(queued.event);
86
- if (queued.event.actionable) {
87
- await this.session.prompt({ sessionID, text });
88
- } else {
89
- await this.session.synthetic({ sessionID, text });
90
- }
138
+ if (!await this.admitted(sessionID, queued)) continue;
139
+ await this.session.synthetic({
140
+ sessionID,
141
+ text: eventToText(queued.event),
142
+ description: `sourcefed ${queued.event.kind}`,
143
+ metadata: { sourcefed: { eventID: queued.id, monitorID: queued.monitorID, kind: queued.event.kind } },
144
+ resume: queued.event.actionable
145
+ });
146
+ }
147
+ }
148
+ /**
149
+ * Ask Chauffeur's gate, when it runs in this process. Only an explicit
150
+ * `deliver: false` withholds the event; no gate, an error, or a slow answer
151
+ * delivers.
152
+ */
153
+ async admitted(sessionID, queued) {
154
+ if (!this.rpc) return true;
155
+ const { event } = queued;
156
+ try {
157
+ const source = event.source?.type;
158
+ const reply = await this.rpc(ChauffeurGate).gate(
159
+ {
160
+ sessionID,
161
+ monitorID: queued.monitorID,
162
+ source: typeof source === "string" ? source : "unknown",
163
+ kind: event.kind,
164
+ summary: event.summary,
165
+ body: event.body ?? "",
166
+ actionable: event.actionable
167
+ },
168
+ { signal: AbortSignal.timeout(GATE_TIMEOUT_MS) }
169
+ );
170
+ return reply.deliver !== false;
171
+ } catch {
172
+ return true;
91
173
  }
92
174
  }
93
175
  target(sessionID) {
94
- return { kind: "opencode-session", id: sessionID };
176
+ return { kind: TARGET_KIND, id: sessionID };
95
177
  }
96
178
  };
179
+ async function connectDaemon() {
180
+ const url = process.env.SOURCEFED_DAEMON_URL;
181
+ if (url) return connectDaemonClient({ name: "sourcefed-opencode", url });
182
+ const local = daemonCommand(cliEntry());
183
+ const spawned = await spawnLocalDaemon({
184
+ command: local.command,
185
+ args: local.args,
186
+ env: daemonEnvironment()
187
+ });
188
+ return connectDaemonClient({ name: "sourcefed-opencode", url: spawned.url });
189
+ }
97
190
  function cliEntry() {
98
191
  const bundled = fileURLToPath(new URL("./cli.js", import.meta.url));
99
192
  if (existsSync(bundled)) return bundled;
@@ -106,12 +199,6 @@ function getOpenCodeBridge() {
106
199
  if (!activeBridge) throw new Error("Sourcefed OpenCode bridge is not initialized");
107
200
  return activeBridge;
108
201
  }
109
- function eventText(event) {
110
- const header = `[sourcefed monitor] ${event.summary}`;
111
- return event.body ? `${header}
112
-
113
- ${event.body}` : header;
114
- }
115
202
 
116
203
  // adapters/opencode/tool-result.ts
117
204
  async function callMonitorTool(name, arguments_, sessionID) {
@@ -219,7 +306,7 @@ var monitor_stop_default = definition5;
219
306
 
220
307
  // adapters/opencode/index.ts
221
308
  async function setupSourcefed(ctx) {
222
- const bridge = new OpenCodeBridge(ctx.session);
309
+ const bridge = new OpenCodeBridge(ctx.session, ctx.rpc);
223
310
  setOpenCodeBridge(bridge);
224
311
  await bridge.start();
225
312
  await ctx.tool.transform((editor) => {
package/dist/pi.js CHANGED
@@ -1,9 +1,12 @@
1
+ import {
2
+ eventToText
3
+ } from "./chunk-BNLZQKEB.js";
1
4
  import {
2
5
  connectDaemonClient,
3
6
  daemonCommand,
4
7
  daemonEnvironment,
5
8
  spawnLocalDaemon
6
- } from "./chunk-QPDTUNFA.js";
9
+ } from "./chunk-X3BZSHKZ.js";
7
10
  import "./chunk-MLKGABMK.js";
8
11
 
9
12
  // adapters/pi/src/index.ts
@@ -248,9 +251,7 @@ async function routeEvents(pi, events) {
248
251
  for (const queued of events) {
249
252
  await pi.sendMessage({
250
253
  customType: "sourcefed-monitor",
251
- content: `[sourcefed monitor] ${queued.event.summary}${queued.event.body ? `
252
-
253
- ${queued.event.body}` : ""}`,
254
+ content: eventToText(queued.event),
254
255
  display: true,
255
256
  details: { actionable: queued.event.actionable, eventID: queued.id }
256
257
  }, { triggerTurn: queued.event.actionable, deliverAs: "steer" });
@@ -23,12 +23,12 @@ import { Show, createMemo, createSignal, onCleanup } from "opentui:runtime-modul
23
23
  import { connectDaemonClient, defaultDaemonUrl } from "@fdcn/sourcefed/daemon";
24
24
  function tone(theme) {
25
25
  return {
26
- text: theme.text.default,
27
- textMuted: theme.text.subdued,
28
- accent: theme.text.action.primary.default,
29
- success: theme.text.feedback.success.default,
30
- error: theme.text.feedback.error.default,
31
- warning: theme.text.feedback.warning.default
26
+ text: theme.text.base,
27
+ textMuted: theme.text.muted,
28
+ accent: theme.text.action.primary.base,
29
+ success: theme.text.feedback.success.base,
30
+ error: theme.text.feedback.error.base,
31
+ warning: theme.text.feedback.warning.base
32
32
  };
33
33
  }
34
34
  var REFRESH_MS = 3e3;
@@ -1,18 +1,34 @@
1
- import type { Plugin } from "@opencode/plugin";
1
+ import { type Plugin } from "@opencode/plugin";
2
2
  type SessionDomain = Plugin.Context["session"];
3
+ type RpcDomain = Plugin.Context["rpc"];
3
4
  export declare class OpenCodeBridge {
4
5
  private readonly session;
6
+ private readonly rpc?;
5
7
  private daemon?;
6
8
  private readonly listeners;
7
9
  private lastAttemptAt;
8
10
  private lastError;
9
- constructor(session: SessionDomain);
11
+ constructor(session: SessionDomain, rpc?: RpcDomain | undefined);
10
12
  start(): Promise<void>;
11
13
  ensureTarget(sessionID: string): Promise<void>;
12
14
  callTool(name: string, arguments_: Record<string, unknown>, sessionID: string): Promise<unknown>;
13
15
  close(): Promise<void>;
14
16
  private ensureDaemon;
17
+ /**
18
+ * Monitors outlive this process, so after a restart nothing listens for their
19
+ * sessions until something subscribes. Subscribe every session that has an
20
+ * enabled monitor and still exists here; a deleted session is skipped, since
21
+ * delivering to it would fail and retry forever.
22
+ */
23
+ private resubscribe;
24
+ private resubscribeTarget;
15
25
  private routeEvents;
26
+ /**
27
+ * Ask Chauffeur's gate, when it runs in this process. Only an explicit
28
+ * `deliver: false` withholds the event; no gate, an error, or a slow answer
29
+ * delivers.
30
+ */
31
+ private admitted;
16
32
  private target;
17
33
  }
18
34
  export declare function setOpenCodeBridge(bridge: OpenCodeBridge): void;
@@ -0,0 +1 @@
1
+ export {};
@@ -2,7 +2,7 @@
2
2
  import { type MonitorView } from "@fdcn/sourcefed/daemon";
3
3
  import type { Plugin } from "@opencode/plugin/tui";
4
4
  type ThemeTokens = Plugin.Context["theme"];
5
- type Color = ThemeTokens["text"]["default"];
5
+ type Color = ThemeTokens["text"]["base"];
6
6
  export type Tone = Record<"text" | "textMuted" | "accent" | "success" | "error" | "warning", Color>;
7
7
  export declare function tone(theme: ThemeTokens): Tone;
8
8
  export type SidebarProps = {
@@ -1 +1,3 @@
1
1
  export declare function flag(args: string[], name: string): string | undefined;
2
+ /** Every value of a repeatable flag, in order: `--name a --name b` gives `["a", "b"]`. */
3
+ export declare function flags(args: string[], name: string): string[];
@@ -0,0 +1 @@
1
+ export {};
@@ -19,6 +19,11 @@ export declare class SourcefedDaemon {
19
19
  webhook(request: Request): Promise<Response>;
20
20
  createMonitor(target: MonitorTarget, input: DaemonCreateInput): Promise<DaemonResult>;
21
21
  listMonitors(target: MonitorTarget): Promise<DaemonResult>;
22
+ /**
23
+ * Every target of `kind` with an enabled monitor, so a host that restarts can
24
+ * resubscribe the sessions whose monitors outlived it.
25
+ */
26
+ listTargets(kind: string): Promise<MonitorTarget[]>;
22
27
  getMonitor(target: MonitorTarget, id: string): Promise<DaemonResult>;
23
28
  stopMonitor(target: MonitorTarget, id: string): Promise<DaemonResult>;
24
29
  startMonitor(target: MonitorTarget, id: string): Promise<DaemonResult>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import type { MonitorEvent, MonitorEventSink, MonitorRecord } from "@fdcn/sourcefed/core";
2
+ export type ForwardOptions = {
3
+ url: string;
4
+ token?: string;
5
+ fetch?: typeof fetch;
6
+ };
7
+ /** The JSON body POSTed for each delivered event. */
8
+ export type ForwardedEvent = {
9
+ target: MonitorRecord["target"];
10
+ monitorID: string;
11
+ source: MonitorRecord["source"];
12
+ event: MonitorEvent;
13
+ };
14
+ /** `SOURCEFED_FORWARD_URL` (and optional `SOURCEFED_FORWARD_TOKEN`), when set. */
15
+ export declare function forwardFromEnv(): ForwardOptions | undefined;
16
+ /**
17
+ * Delivers to `inner`, then POSTs a copy of each delivered event to another
18
+ * local consumer. Forwarding never blocks or fails delivery.
19
+ */
20
+ export declare class ForwardingEventSink implements MonitorEventSink {
21
+ private readonly inner;
22
+ private readonly options;
23
+ constructor(inner: MonitorEventSink, options: ForwardOptions);
24
+ deliver(input: {
25
+ monitor: MonitorRecord;
26
+ event: MonitorEvent;
27
+ }): Promise<{
28
+ ok: boolean;
29
+ error?: string;
30
+ }>;
31
+ private forward;
32
+ }
@@ -0,0 +1,33 @@
1
+ import type { MonitorEvent, MonitorEventSink, MonitorRecord } from "@fdcn/sourcefed/core";
2
+ export type GateOptions = {
3
+ url: string;
4
+ token?: string;
5
+ fetch?: typeof fetch;
6
+ };
7
+ /** The JSON body POSTed for each event; the gate replies `{ "deliver": boolean }`. */
8
+ export type GatedEvent = {
9
+ target: MonitorRecord["target"];
10
+ monitorID: string;
11
+ source: MonitorRecord["source"];
12
+ event: MonitorEvent;
13
+ };
14
+ /** `SOURCEFED_GATE_URL` (and optional `SOURCEFED_GATE_TOKEN`), when set. */
15
+ export declare function gateFromEnv(): GateOptions | undefined;
16
+ /**
17
+ * Asks another local consumer whether each event should reach the session.
18
+ * Only an explicit `{ "deliver": false }` withholds it; a failed, slow, or
19
+ * unclear answer delivers, so the gate can never lose an event.
20
+ */
21
+ export declare class GatedEventSink implements MonitorEventSink {
22
+ private readonly inner;
23
+ private readonly options;
24
+ constructor(inner: MonitorEventSink, options: GateOptions);
25
+ deliver(input: {
26
+ monitor: MonitorRecord;
27
+ event: MonitorEvent;
28
+ }): Promise<{
29
+ ok: boolean;
30
+ error?: string;
31
+ }>;
32
+ private withheld;
33
+ }
@@ -1,8 +1,16 @@
1
+ type SlackUser = {
2
+ id: string;
3
+ name: string;
4
+ real_name: string;
5
+ };
1
6
  export declare function fetchSlackThread(channelId: string, threadTs: string): Promise<{
2
7
  messages: any[];
3
- users: any[];
4
- } | undefined>;
8
+ users: SlackUser[];
9
+ }>;
5
10
  export declare function fetchSlackDm(channelId: string): Promise<{
6
11
  messages: any[];
7
- users: any[];
8
- } | undefined>;
12
+ users: SlackUser[];
13
+ }>;
14
+ /** Forget the cached user list, so a test starts from an empty cache. */
15
+ export declare function resetSlackUsers(): void;
16
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fdcn/sourcefed",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Live Jira, GitHub, and Slack monitoring for agent hosts: CLI, daemon, MCP server, and host plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  "@earendil-works/pi-coding-agent": "0.83.0",
52
52
  "@earendil-works/pi-tui": "0.83.0",
53
53
  "@modelcontextprotocol/server": "2.0.0",
54
- "@opencode/plugin": "2.0.4",
54
+ "@opencode/plugin": "2.0.12",
55
55
  "typebox": "1.3.10"
56
56
  },
57
57
  "engines": {