@getfamiliar/shared 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.
Files changed (83) hide show
  1. package/build/AgentRun.d.ts +187 -0
  2. package/build/AgentRun.js +1 -0
  3. package/build/AgentRunBus.d.ts +147 -0
  4. package/build/AgentRunBus.js +362 -0
  5. package/build/Calendar.d.ts +450 -0
  6. package/build/Calendar.js +37 -0
  7. package/build/ChatMessage.d.ts +53 -0
  8. package/build/ChatMessage.js +11 -0
  9. package/build/ChatMessageBus.d.ts +110 -0
  10. package/build/ChatMessageBus.js +257 -0
  11. package/build/Config.d.ts +83 -0
  12. package/build/Config.js +1 -0
  13. package/build/ContainerTool.d.ts +34 -0
  14. package/build/ContainerTool.js +1 -0
  15. package/build/CronExpression.d.ts +53 -0
  16. package/build/CronExpression.js +77 -0
  17. package/build/Event.d.ts +280 -0
  18. package/build/Event.js +14 -0
  19. package/build/EventBus.d.ts +198 -0
  20. package/build/EventBus.js +482 -0
  21. package/build/HandlerCatalog.d.ts +75 -0
  22. package/build/HandlerCatalog.js +165 -0
  23. package/build/InferenceEvent.d.ts +30 -0
  24. package/build/InferenceEvent.js +1 -0
  25. package/build/InferenceEventBus.d.ts +39 -0
  26. package/build/InferenceEventBus.js +80 -0
  27. package/build/Mail.d.ts +250 -0
  28. package/build/Mail.js +73 -0
  29. package/build/MailStyleTemplate.d.ts +71 -0
  30. package/build/MailStyleTemplate.js +47 -0
  31. package/build/ModelMetaData.d.ts +63 -0
  32. package/build/ModelMetaData.js +12 -0
  33. package/build/PathGlob.d.ts +20 -0
  34. package/build/PathGlob.js +33 -0
  35. package/build/Plugin.d.ts +709 -0
  36. package/build/Plugin.js +29 -0
  37. package/build/PostgresConnection.d.ts +75 -0
  38. package/build/PostgresConnection.js +162 -0
  39. package/build/ScheduledSubagent.d.ts +49 -0
  40. package/build/ScheduledSubagent.js +1 -0
  41. package/build/ScheduledSubagentBus.d.ts +94 -0
  42. package/build/ScheduledSubagentBus.js +187 -0
  43. package/build/Schema.d.ts +84 -0
  44. package/build/Schema.js +570 -0
  45. package/build/StepResult.d.ts +103 -0
  46. package/build/StepResult.js +1 -0
  47. package/build/StepResultBus.d.ts +64 -0
  48. package/build/StepResultBus.js +169 -0
  49. package/build/Timezone.d.ts +58 -0
  50. package/build/Timezone.js +84 -0
  51. package/build/ToolCall.d.ts +25 -0
  52. package/build/ToolCall.js +1 -0
  53. package/build/ToolCallBus.d.ts +65 -0
  54. package/build/ToolCallBus.js +110 -0
  55. package/build/ToolLevel.d.ts +20 -0
  56. package/build/ToolLevel.js +2 -0
  57. package/build/ToolRunner.d.ts +118 -0
  58. package/build/ToolRunner.js +192 -0
  59. package/build/ToolsExpressionUtils.d.ts +80 -0
  60. package/build/ToolsExpressionUtils.js +102 -0
  61. package/build/WorkspaceFile.d.ts +104 -0
  62. package/build/WorkspaceFile.js +7 -0
  63. package/build/estimateTokens.d.ts +16 -0
  64. package/build/estimateTokens.js +18 -0
  65. package/build/index.d.ts +37 -0
  66. package/build/index.js +26 -0
  67. package/build/logging/Logger.d.ts +66 -0
  68. package/build/logging/Logger.js +90 -0
  69. package/build/markdownHtml.d.ts +14 -0
  70. package/build/markdownHtml.js +21 -0
  71. package/build/markdownTerminal.d.ts +14 -0
  72. package/build/markdownTerminal.js +376 -0
  73. package/build/testing/MockAgentRunBus.d.ts +52 -0
  74. package/build/testing/MockAgentRunBus.js +175 -0
  75. package/build/testing/MockBusStore.d.ts +22 -0
  76. package/build/testing/MockBusStore.js +22 -0
  77. package/build/testing/MockEventBus.d.ts +20 -0
  78. package/build/testing/MockEventBus.js +57 -0
  79. package/build/testing/MockLogger.d.ts +52 -0
  80. package/build/testing/MockLogger.js +65 -0
  81. package/build/testing/index.d.ts +4 -0
  82. package/build/testing/index.js +4 -0
  83. package/package.json +63 -0
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Thrown by `ctx.events.emit().settled` when the host daemon stops
3
+ * (or crashes) while a one-shot CLI command was waiting for the event
4
+ * to reach a terminal state. Catch and exit cleanly — there is no
5
+ * postgres on the other side to recover state from.
6
+ */
7
+ export class DaemonStoppedError extends Error {
8
+ constructor(message = "Host daemon stopped") {
9
+ super(message);
10
+ this.name = "DaemonStoppedError";
11
+ }
12
+ }
13
+ /**
14
+ * Identity function used by plugins to declare their manifest:
15
+ *
16
+ * ```ts
17
+ * export default definePlugin({
18
+ * id: "cli-chat",
19
+ * host: { commands: (ctx) => [sendCommand(ctx)] },
20
+ * });
21
+ * ```
22
+ *
23
+ * Exists purely so TypeScript narrows literal types correctly without
24
+ * the caller writing a `satisfies PluginManifest` clause. Same pattern
25
+ * as Vite's `defineConfig` / Astro's `defineConfig`.
26
+ */
27
+ export function definePlugin(plugin) {
28
+ return plugin;
29
+ }
@@ -0,0 +1,75 @@
1
+ import { type Pool } from "pg";
2
+ /**
3
+ * Hardcoded user / database / network coordinates for the dev bus-state
4
+ * DB. Both ends of the bus import these so they don't drift.
5
+ *
6
+ * - {@link POSTGRES_HOST} is both the docker container `--name` (used by
7
+ * `PostgresContainer` on the host) and the DNS name the agent
8
+ * container resolves over `familiar-net`.
9
+ * - {@link POSTGRES_PORT} is the in-container postgres port — the
10
+ * *internal* side of the host's loopback `-p` mapping, and the port
11
+ * the agent container connects to over `familiar-net`.
12
+ *
13
+ * The password is *not* a constant — it lives in `.env`
14
+ * (`POSTGRES_PASSWORD`) and is passed explicitly to
15
+ * `PostgresConnection`.
16
+ */
17
+ export declare const POSTGRES_HOST = "familiar-postgres";
18
+ export declare const POSTGRES_PORT = 5432;
19
+ export declare const POSTGRES_USER = "familiar";
20
+ export declare const POSTGRES_DB = "familiar";
21
+ /** Connection coordinates for a postgres database. */
22
+ export interface PostgresConnectionConfig {
23
+ readonly host: string;
24
+ readonly port: number;
25
+ readonly user: string;
26
+ readonly password: string;
27
+ readonly database: string;
28
+ }
29
+ /** Callback fired when a NOTIFY arrives on a subscribed channel. */
30
+ export type NotificationHandler = (payload: string) => void;
31
+ /**
32
+ * Wraps a postgres pool plus an optional dedicated `LISTEN` client.
33
+ *
34
+ * - `getPool()` lazily builds a normal connection pool for queries.
35
+ * - `listen()`/`unlisten()` lazily open a single long-lived client
36
+ * dedicated to receiving asynchronous NOTIFYs. Multiple consumers can
37
+ * subscribe to the same channel; each registered handler is invoked
38
+ * for every notification on that channel.
39
+ *
40
+ * Domain classes (e.g. `EventBus`) take a `PostgresConnection` rather
41
+ * than connection coordinates so they can share one pool / one listen
42
+ * client across the process.
43
+ */
44
+ export declare class PostgresConnection {
45
+ private readonly poolConfig;
46
+ private pool;
47
+ private listenClient;
48
+ private listenClientReady;
49
+ private readonly handlersByChannel;
50
+ constructor(config: PostgresConnectionConfig);
51
+ /** Lazily build (and reuse) the connection pool for normal queries. */
52
+ getPool(): Pool;
53
+ /**
54
+ * Subscribe to a postgres NOTIFY channel. The dedicated LISTEN
55
+ * connection is opened on first call. Multiple handlers per channel
56
+ * are supported; the same handler can be registered only once.
57
+ *
58
+ * @param channel - SQL identifier for the channel. Must already be
59
+ * safe to interpolate (validated to avoid injection).
60
+ * @param handler - Callback invoked with each notification's payload.
61
+ */
62
+ listen(channel: string, handler: NotificationHandler): Promise<void>;
63
+ /**
64
+ * Remove a previously registered handler. Issues `UNLISTEN` once the
65
+ * last handler for a channel is removed.
66
+ */
67
+ unlisten(channel: string, handler: NotificationHandler): Promise<void>;
68
+ /** End the pool and the LISTEN client. Idempotent. */
69
+ close(): Promise<void>;
70
+ /**
71
+ * Ensure the dedicated LISTEN client is connected and dispatch
72
+ * notifications to all registered handlers for the channel.
73
+ */
74
+ private ensureListenClient;
75
+ }
@@ -0,0 +1,162 @@
1
+ import { default as pg } from "pg";
2
+ const { Pool: PgPool, Client: PgClient } = pg;
3
+ /**
4
+ * Hardcoded user / database / network coordinates for the dev bus-state
5
+ * DB. Both ends of the bus import these so they don't drift.
6
+ *
7
+ * - {@link POSTGRES_HOST} is both the docker container `--name` (used by
8
+ * `PostgresContainer` on the host) and the DNS name the agent
9
+ * container resolves over `familiar-net`.
10
+ * - {@link POSTGRES_PORT} is the in-container postgres port — the
11
+ * *internal* side of the host's loopback `-p` mapping, and the port
12
+ * the agent container connects to over `familiar-net`.
13
+ *
14
+ * The password is *not* a constant — it lives in `.env`
15
+ * (`POSTGRES_PASSWORD`) and is passed explicitly to
16
+ * `PostgresConnection`.
17
+ */
18
+ export const POSTGRES_HOST = "familiar-postgres";
19
+ export const POSTGRES_PORT = 5432;
20
+ export const POSTGRES_USER = "familiar";
21
+ export const POSTGRES_DB = "familiar";
22
+ /**
23
+ * Wraps a postgres pool plus an optional dedicated `LISTEN` client.
24
+ *
25
+ * - `getPool()` lazily builds a normal connection pool for queries.
26
+ * - `listen()`/`unlisten()` lazily open a single long-lived client
27
+ * dedicated to receiving asynchronous NOTIFYs. Multiple consumers can
28
+ * subscribe to the same channel; each registered handler is invoked
29
+ * for every notification on that channel.
30
+ *
31
+ * Domain classes (e.g. `EventBus`) take a `PostgresConnection` rather
32
+ * than connection coordinates so they can share one pool / one listen
33
+ * client across the process.
34
+ */
35
+ export class PostgresConnection {
36
+ poolConfig;
37
+ pool;
38
+ listenClient;
39
+ listenClientReady;
40
+ handlersByChannel = new Map();
41
+ constructor(config) {
42
+ this.poolConfig = {
43
+ host: config.host,
44
+ port: config.port,
45
+ user: config.user,
46
+ password: config.password,
47
+ database: config.database,
48
+ };
49
+ }
50
+ /** Lazily build (and reuse) the connection pool for normal queries. */
51
+ getPool() {
52
+ if (!this.pool) {
53
+ this.pool = new PgPool(this.poolConfig);
54
+ }
55
+ return this.pool;
56
+ }
57
+ /**
58
+ * Subscribe to a postgres NOTIFY channel. The dedicated LISTEN
59
+ * connection is opened on first call. Multiple handlers per channel
60
+ * are supported; the same handler can be registered only once.
61
+ *
62
+ * @param channel - SQL identifier for the channel. Must already be
63
+ * safe to interpolate (validated to avoid injection).
64
+ * @param handler - Callback invoked with each notification's payload.
65
+ */
66
+ async listen(channel, handler) {
67
+ validateChannelName(channel);
68
+ const client = await this.ensureListenClient();
69
+ let handlers = this.handlersByChannel.get(channel);
70
+ const isFirstSubscriber = !handlers;
71
+ if (!handlers) {
72
+ handlers = new Set();
73
+ this.handlersByChannel.set(channel, handlers);
74
+ }
75
+ handlers.add(handler);
76
+ if (isFirstSubscriber) {
77
+ await client.query(`LISTEN ${channel}`);
78
+ }
79
+ }
80
+ /**
81
+ * Remove a previously registered handler. Issues `UNLISTEN` once the
82
+ * last handler for a channel is removed.
83
+ */
84
+ async unlisten(channel, handler) {
85
+ validateChannelName(channel);
86
+ const handlers = this.handlersByChannel.get(channel);
87
+ if (!handlers) {
88
+ return;
89
+ }
90
+ handlers.delete(handler);
91
+ if (handlers.size === 0) {
92
+ this.handlersByChannel.delete(channel);
93
+ if (this.listenClient) {
94
+ await this.listenClient.query(`UNLISTEN ${channel}`);
95
+ }
96
+ }
97
+ }
98
+ /** End the pool and the LISTEN client. Idempotent. */
99
+ async close() {
100
+ this.handlersByChannel.clear();
101
+ if (this.listenClient) {
102
+ try {
103
+ await this.listenClient.end();
104
+ }
105
+ catch {
106
+ // best-effort
107
+ }
108
+ this.listenClient = undefined;
109
+ this.listenClientReady = undefined;
110
+ }
111
+ if (this.pool) {
112
+ await this.pool.end();
113
+ this.pool = undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Ensure the dedicated LISTEN client is connected and dispatch
118
+ * notifications to all registered handlers for the channel.
119
+ */
120
+ async ensureListenClient() {
121
+ if (!this.listenClientReady) {
122
+ this.listenClientReady = (async () => {
123
+ const client = new PgClient(this.poolConfig);
124
+ await client.connect();
125
+ client.on("notification", (msg) => {
126
+ if (!msg.channel) {
127
+ return;
128
+ }
129
+ const handlers = this.handlersByChannel.get(msg.channel);
130
+ if (!handlers) {
131
+ return;
132
+ }
133
+ for (const handler of handlers) {
134
+ try {
135
+ handler(msg.payload ?? "");
136
+ }
137
+ catch (err) {
138
+ console.error(`PostgresConnection notification handler error: ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ }
141
+ });
142
+ client.on("error", (err) => {
143
+ console.error(`PostgresConnection listen client error: ${err.message}`);
144
+ });
145
+ this.listenClient = client;
146
+ return client;
147
+ })();
148
+ }
149
+ return this.listenClientReady;
150
+ }
151
+ }
152
+ const CHANNEL_NAME_RE = /^[a-z_][a-z0-9_]*$/i;
153
+ /**
154
+ * Reject channel names that aren't safe to interpolate into a `LISTEN`
155
+ * statement. Postgres identifiers can be quoted, but for simplicity we
156
+ * restrict to plain `[a-z_][a-z0-9_]*`.
157
+ */
158
+ function validateChannelName(channel) {
159
+ if (!CHANNEL_NAME_RE.test(channel)) {
160
+ throw new Error(`Invalid channel name: ${channel}`);
161
+ }
162
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A persisted row in `scheduled_subagents` — one future one-off wake-up
3
+ * the agent has asked the host to fire. Only produced when the agent
4
+ * called `schedule_subagent` with a future `when`; without `when`, the
5
+ * tool inserts a child agentrun directly instead and never touches
6
+ * this table. Lifecycle:
7
+ *
8
+ * 1. Container's `schedule_subagent` tool upserts (on `key`) a row with
9
+ * `fireAt` in UTC, copying the calling agentrun's `priority` and
10
+ * `privileged` flag.
11
+ * 2. Host's `ScheduledSubagentScheduler` reacts to the
12
+ * `scheduled_subagents_changed` NOTIFY and installs a Croner job for
13
+ * `fireAt` with `maxRuns: 1`.
14
+ * 3. When the job fires, the host atomically claims-and-deletes the
15
+ * row, then emits a fresh event with the row's topic / handler /
16
+ * prompt / payload / priority / privileged.
17
+ *
18
+ * `prompt` is optional at the table level so the agent can schedule
19
+ * "the handler knows what to do" wake-ups; the firing path supplies
20
+ * a generic default (`Scheduled subagent fired`) before calling
21
+ * `EventBus.add`, which requires a non-empty prompt.
22
+ */
23
+ export interface ScheduledSubagentRow {
24
+ /** Agent-supplied unique id. UPSERT target. */
25
+ readonly key: string;
26
+ /** UTC ISO-8601 firing time (e.g. `2026-05-21T13:00:00Z`). */
27
+ readonly fireAt: string;
28
+ readonly topic: string;
29
+ readonly handler: string;
30
+ readonly prompt: string | null;
31
+ readonly payload: unknown;
32
+ readonly priority: number;
33
+ readonly privileged: boolean;
34
+ readonly createdAt: Date;
35
+ }
36
+ /**
37
+ * Insert shape for {@link ScheduledSubagentBus.upsert}. Mirrors
38
+ * {@link ScheduledSubagentRow} minus `createdAt` (filled by the DB).
39
+ */
40
+ export interface NewScheduledSubagent {
41
+ readonly key: string;
42
+ readonly fireAt: string;
43
+ readonly topic: string;
44
+ readonly handler: string;
45
+ readonly prompt?: string | null;
46
+ readonly payload?: unknown;
47
+ readonly priority?: number;
48
+ readonly privileged?: boolean;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import type { Logger } from "./logging/Logger.js";
2
+ import type { PostgresConnection } from "./PostgresConnection.js";
3
+ import type { NewScheduledSubagent, ScheduledSubagentRow } from "./ScheduledSubagent.js";
4
+ /** Disposer returned by {@link ScheduledSubagentBus.listen}. */
5
+ export type ScheduledSubagentUnsubscribe = () => Promise<void>;
6
+ /** Notification operation extracted from a NOTIFY payload. */
7
+ export type ScheduledSubagentOp = "u" | "d";
8
+ /** Parsed notification handed to {@link ScheduledSubagentBus.listen} subscribers. */
9
+ export interface ScheduledSubagentNotification {
10
+ readonly key: string;
11
+ readonly op: ScheduledSubagentOp;
12
+ }
13
+ /**
14
+ * Domain client for the `scheduled_subagents` table — agent-requested
15
+ * one-off wake-ups. Used by both sides of the bus:
16
+ *
17
+ * - Container: the `schedule_subagent` / `unschedule_subagent` /
18
+ * `get_scheduled_subagents` tools call {@link upsert} / {@link deleteByKey}
19
+ * / {@link listInRange}.
20
+ * - Host: the `ScheduledSubagentScheduler` calls {@link listFuture} at
21
+ * startup, listens via {@link listen} for live changes, and uses
22
+ * {@link claimAndDeleteForFiring} to consume a row at fire time.
23
+ *
24
+ * All times are stored and returned as UTC ISO-8601 strings; the
25
+ * caller is responsible for projecting into the user's
26
+ * `core.timezone` for agent-facing surfaces.
27
+ */
28
+ export declare class ScheduledSubagentBus {
29
+ private readonly connection;
30
+ private readonly log;
31
+ constructor(connection: PostgresConnection, log?: Logger);
32
+ /**
33
+ * Apply the bus-state schema (idempotent). Same SQL bundle as
34
+ * {@link import("./EventBus").EventBus.installSchema}, so calling
35
+ * any of the three install methods is sufficient on daemon start.
36
+ */
37
+ installSchema(): Promise<void>;
38
+ /**
39
+ * Insert a scheduled subagent or replace the existing row with the
40
+ * same `key`. Returns the persisted row.
41
+ *
42
+ * Atomic UPSERT semantics: if the agent re-schedules a key, the
43
+ * old row is overwritten in one statement so the host scheduler
44
+ * sees exactly one NOTIFY and installs exactly one job.
45
+ *
46
+ * @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
47
+ */
48
+ upsert(row: NewScheduledSubagent): Promise<ScheduledSubagentRow>;
49
+ /**
50
+ * Remove a row by key. Returns `true` when a row was removed,
51
+ * `false` when no row existed for the key.
52
+ */
53
+ deleteByKey(key: string): Promise<boolean>;
54
+ /** Fetch a row by key, or `undefined` when missing. */
55
+ getByKey(key: string): Promise<ScheduledSubagentRow | undefined>;
56
+ /**
57
+ * Atomic claim-and-delete for the firing path: DELETE the row by
58
+ * key and return it (or `undefined` if it was already gone — the
59
+ * agent may have unscheduled the key in the window between the
60
+ * Croner trigger and this query).
61
+ */
62
+ claimAndDeleteForFiring(key: string): Promise<ScheduledSubagentRow | undefined>;
63
+ /**
64
+ * List every row whose `fire_at` falls inside `[fromUtc, toUtc)`,
65
+ * ordered by `fire_at ASC`. Range is half-open so a `day` query
66
+ * built from {@link import("./Timezone").dayBoundsInZone} returns
67
+ * exactly the rows belonging to that day in the caller's zone.
68
+ */
69
+ listInRange(fromUtc: string, toUtc: string): Promise<ScheduledSubagentRow[]>;
70
+ /**
71
+ * Load every row whose `fire_at` is at or after `nowUtc`. Used by
72
+ * the host scheduler on daemon startup to re-install Croner jobs
73
+ * for every still-future scheduled subagent.
74
+ */
75
+ listFuture(nowUtc: string): Promise<ScheduledSubagentRow[]>;
76
+ /**
77
+ * Delete every row whose `fire_at` is strictly before `nowUtc`.
78
+ * Returns the number of rows removed. Used by the host scheduler
79
+ * at startup to silently drop missed wake-ups (matches missed-cron
80
+ * behavior).
81
+ */
82
+ deletePastDue(nowUtc: string): Promise<number>;
83
+ /**
84
+ * Subscribe to {@link SCHEDULED_SUBAGENTS_CHANNEL}. Parses the
85
+ * NOTIFY payload (`<key>:<op>`) and invokes `handler` with the
86
+ * parsed shape. Errors thrown by `handler` are logged and
87
+ * swallowed so one bad subscriber doesn't break others on the
88
+ * same channel.
89
+ *
90
+ * Returns a disposer; calling it unlistens. Idempotent.
91
+ */
92
+ listen(handler: (notification: ScheduledSubagentNotification) => void | Promise<void>): Promise<ScheduledSubagentUnsubscribe>;
93
+ private dispatch;
94
+ }
@@ -0,0 +1,187 @@
1
+ import { SCHEDULED_SUBAGENTS_CHANNEL, SCHEMA_SQL } from "./Schema.js";
2
+ /**
3
+ * Domain client for the `scheduled_subagents` table — agent-requested
4
+ * one-off wake-ups. Used by both sides of the bus:
5
+ *
6
+ * - Container: the `schedule_subagent` / `unschedule_subagent` /
7
+ * `get_scheduled_subagents` tools call {@link upsert} / {@link deleteByKey}
8
+ * / {@link listInRange}.
9
+ * - Host: the `ScheduledSubagentScheduler` calls {@link listFuture} at
10
+ * startup, listens via {@link listen} for live changes, and uses
11
+ * {@link claimAndDeleteForFiring} to consume a row at fire time.
12
+ *
13
+ * All times are stored and returned as UTC ISO-8601 strings; the
14
+ * caller is responsible for projecting into the user's
15
+ * `core.timezone` for agent-facing surfaces.
16
+ */
17
+ export class ScheduledSubagentBus {
18
+ connection;
19
+ log;
20
+ constructor(connection, log) {
21
+ this.connection = connection;
22
+ this.log = log;
23
+ }
24
+ /**
25
+ * Apply the bus-state schema (idempotent). Same SQL bundle as
26
+ * {@link import("./EventBus").EventBus.installSchema}, so calling
27
+ * any of the three install methods is sufficient on daemon start.
28
+ */
29
+ async installSchema() {
30
+ await this.connection.getPool().query(SCHEMA_SQL);
31
+ }
32
+ /**
33
+ * Insert a scheduled subagent or replace the existing row with the
34
+ * same `key`. Returns the persisted row.
35
+ *
36
+ * Atomic UPSERT semantics: if the agent re-schedules a key, the
37
+ * old row is overwritten in one statement so the host scheduler
38
+ * sees exactly one NOTIFY and installs exactly one job.
39
+ *
40
+ * @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
41
+ */
42
+ async upsert(row) {
43
+ const result = await this.connection.getPool().query(`INSERT INTO scheduled_subagents
44
+ (key, fire_at, topic, handler, prompt, payload, priority, privileged)
45
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
46
+ ON CONFLICT (key) DO UPDATE SET
47
+ fire_at = EXCLUDED.fire_at,
48
+ topic = EXCLUDED.topic,
49
+ handler = EXCLUDED.handler,
50
+ prompt = EXCLUDED.prompt,
51
+ payload = EXCLUDED.payload,
52
+ priority = EXCLUDED.priority,
53
+ privileged = EXCLUDED.privileged
54
+ RETURNING *`, [
55
+ row.key,
56
+ row.fireAt,
57
+ row.topic,
58
+ row.handler,
59
+ row.prompt ?? null,
60
+ JSON.stringify(row.payload ?? {}),
61
+ row.priority ?? 50,
62
+ row.privileged ?? false,
63
+ ]);
64
+ return mapRow(result.rows[0]);
65
+ }
66
+ /**
67
+ * Remove a row by key. Returns `true` when a row was removed,
68
+ * `false` when no row existed for the key.
69
+ */
70
+ async deleteByKey(key) {
71
+ const result = await this.connection
72
+ .getPool()
73
+ .query(`DELETE FROM scheduled_subagents WHERE key = $1`, [key]);
74
+ return (result.rowCount ?? 0) > 0;
75
+ }
76
+ /** Fetch a row by key, or `undefined` when missing. */
77
+ async getByKey(key) {
78
+ const result = await this.connection
79
+ .getPool()
80
+ .query(`SELECT * FROM scheduled_subagents WHERE key = $1`, [key]);
81
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
82
+ }
83
+ /**
84
+ * Atomic claim-and-delete for the firing path: DELETE the row by
85
+ * key and return it (or `undefined` if it was already gone — the
86
+ * agent may have unscheduled the key in the window between the
87
+ * Croner trigger and this query).
88
+ */
89
+ async claimAndDeleteForFiring(key) {
90
+ const result = await this.connection
91
+ .getPool()
92
+ .query(`DELETE FROM scheduled_subagents WHERE key = $1 RETURNING *`, [key]);
93
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
94
+ }
95
+ /**
96
+ * List every row whose `fire_at` falls inside `[fromUtc, toUtc)`,
97
+ * ordered by `fire_at ASC`. Range is half-open so a `day` query
98
+ * built from {@link import("./Timezone").dayBoundsInZone} returns
99
+ * exactly the rows belonging to that day in the caller's zone.
100
+ */
101
+ async listInRange(fromUtc, toUtc) {
102
+ const result = await this.connection.getPool().query(`SELECT * FROM scheduled_subagents
103
+ WHERE fire_at >= $1 AND fire_at < $2
104
+ ORDER BY fire_at ASC`, [fromUtc, toUtc]);
105
+ return result.rows.map(mapRow);
106
+ }
107
+ /**
108
+ * Load every row whose `fire_at` is at or after `nowUtc`. Used by
109
+ * the host scheduler on daemon startup to re-install Croner jobs
110
+ * for every still-future scheduled subagent.
111
+ */
112
+ async listFuture(nowUtc) {
113
+ const result = await this.connection.getPool().query(`SELECT * FROM scheduled_subagents
114
+ WHERE fire_at >= $1
115
+ ORDER BY fire_at ASC`, [nowUtc]);
116
+ return result.rows.map(mapRow);
117
+ }
118
+ /**
119
+ * Delete every row whose `fire_at` is strictly before `nowUtc`.
120
+ * Returns the number of rows removed. Used by the host scheduler
121
+ * at startup to silently drop missed wake-ups (matches missed-cron
122
+ * behavior).
123
+ */
124
+ async deletePastDue(nowUtc) {
125
+ const result = await this.connection
126
+ .getPool()
127
+ .query(`DELETE FROM scheduled_subagents WHERE fire_at < $1`, [nowUtc]);
128
+ return result.rowCount ?? 0;
129
+ }
130
+ /**
131
+ * Subscribe to {@link SCHEDULED_SUBAGENTS_CHANNEL}. Parses the
132
+ * NOTIFY payload (`<key>:<op>`) and invokes `handler` with the
133
+ * parsed shape. Errors thrown by `handler` are logged and
134
+ * swallowed so one bad subscriber doesn't break others on the
135
+ * same channel.
136
+ *
137
+ * Returns a disposer; calling it unlistens. Idempotent.
138
+ */
139
+ async listen(handler) {
140
+ const wrapper = (payload) => {
141
+ this.log?.debug({ channel: SCHEDULED_SUBAGENTS_CHANNEL, payload }, "NOTIFY scheduled_subagents_changed");
142
+ void this.dispatch(payload, handler);
143
+ };
144
+ await this.connection.listen(SCHEDULED_SUBAGENTS_CHANNEL, wrapper);
145
+ let disposed = false;
146
+ return async () => {
147
+ if (disposed) {
148
+ return;
149
+ }
150
+ disposed = true;
151
+ await this.connection.unlisten(SCHEDULED_SUBAGENTS_CHANNEL, wrapper);
152
+ };
153
+ }
154
+ async dispatch(payload, handler) {
155
+ const colon = payload.lastIndexOf(":");
156
+ if (colon < 0) {
157
+ this.log?.warn({ payload }, "ScheduledSubagentBus: malformed NOTIFY payload (no colon)");
158
+ return;
159
+ }
160
+ const key = payload.slice(0, colon);
161
+ const op = payload.slice(colon + 1);
162
+ if (op !== "u" && op !== "d") {
163
+ this.log?.warn({ payload, op }, "ScheduledSubagentBus: unknown op in NOTIFY payload");
164
+ return;
165
+ }
166
+ try {
167
+ await handler({ key, op });
168
+ }
169
+ catch (err) {
170
+ this.log?.error({ key, op, err: err instanceof Error ? err.message : String(err) }, "ScheduledSubagentBus listen handler error");
171
+ }
172
+ }
173
+ }
174
+ /** Convert a snake_case raw row into the camelCase domain shape. */
175
+ function mapRow(raw) {
176
+ return {
177
+ key: raw.key,
178
+ fireAt: raw.fire_at.toISOString(),
179
+ topic: raw.topic,
180
+ handler: raw.handler,
181
+ prompt: raw.prompt,
182
+ payload: raw.payload,
183
+ priority: raw.priority,
184
+ privileged: raw.privileged,
185
+ createdAt: raw.created_at,
186
+ };
187
+ }