@hyperdrive.bot/fleet-server 0.3.147 → 0.3.149

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 (33) hide show
  1. package/dist/server/server/agent/mcp-server.js +64 -2
  2. package/dist/server/server/agent/session-digest-generator.js +33 -2
  3. package/dist/server/server/agent/session-digest.d.ts +71 -0
  4. package/dist/server/server/agent/session-digest.js +117 -1
  5. package/dist/server/server/agent/tools/paseo-tools.d.ts +9 -0
  6. package/dist/server/server/agent/tools/paseo-tools.js +76 -1
  7. package/dist/server/server/agent/tools/read-only-surface.d.ts +7 -0
  8. package/dist/server/server/agent/tools/read-only-surface.js +8 -0
  9. package/dist/server/server/bootstrap.js +71 -1
  10. package/dist/server/server/exports.d.ts +2 -0
  11. package/dist/server/server/exports.js +6 -0
  12. package/dist/server/server/ingestion/subscriptions/notification-prompt.d.ts +83 -0
  13. package/dist/server/server/ingestion/subscriptions/notification-prompt.js +96 -0
  14. package/dist/server/server/ingestion/subscriptions/notifier.d.ts +96 -0
  15. package/dist/server/server/ingestion/subscriptions/notifier.js +209 -0
  16. package/dist/server/server/ingestion/subscriptions/pending-store.d.ts +111 -0
  17. package/dist/server/server/ingestion/subscriptions/pending-store.js +254 -0
  18. package/dist/server/server/ingestion/subscriptions/poller.d.ts +73 -0
  19. package/dist/server/server/ingestion/subscriptions/poller.js +168 -0
  20. package/dist/server/server/ingestion/subscriptions/reader.d.ts +39 -0
  21. package/dist/server/server/ingestion/subscriptions/reader.js +30 -0
  22. package/dist/server/server/ingestion/subscriptions/store.d.ts +35 -0
  23. package/dist/server/server/ingestion/subscriptions/store.js +82 -0
  24. package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js → index-8747c529e5cb02149fe51570f7cb697b.js} +13 -13
  25. package/dist/server/web-ui/_expo/static/js/web/index-8747c529e5cb02149fe51570f7cb697b.js.br +0 -0
  26. package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js.gz → index-8747c529e5cb02149fe51570f7cb697b.js.gz} +0 -0
  27. package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js.map.br → index-8747c529e5cb02149fe51570f7cb697b.js.map.br} +0 -0
  28. package/dist/server/web-ui/_expo/static/js/web/{index-837630304edbf229f37aaa1d262c40d5.js.map.gz → index-8747c529e5cb02149fe51570f7cb697b.js.map.gz} +0 -0
  29. package/dist/server/web-ui/index.html +1 -1
  30. package/dist/server/web-ui/index.html.br +0 -0
  31. package/dist/server/web-ui/index.html.gz +0 -0
  32. package/package.json +6 -6
  33. package/dist/server/web-ui/_expo/static/js/web/index-837630304edbf229f37aaa1d262c40d5.js.br +0 -0
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { WorkflowNotFoundError } from "../workflow/workflow-manager.js";
4
- import { WorkflowAgentPresetSchema, WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, } from "../messages.js";
4
+ import { WorkflowAgentPresetSchema, WorkflowSnapshotSchema, WorkflowStatusSchema, WorkflowTaskGraphSchema, SessionDigestSchema, PendingDecisionSchema, } from "../messages.js";
5
5
  import { normalizeSecretForStorage } from "@hyperdrive.bot/fleet-protocol/session-secrets";
6
6
  import { isTagColorName } from "@hyperdrive.bot/fleet-protocol/tag-color";
7
- import { mergePendingDecisions } from "./session-digest.js";
7
+ import { answerPendingDecision, applyDecisionExpiry, mergePendingDecisions, } from "./session-digest.js";
8
8
  import { AgentStatusEnum } from "./mcp-shared.js";
9
9
  import { expandUserPath } from "../path-utils.js";
10
10
  import { ensureValidJson } from "../json-utils.js";
@@ -605,6 +605,7 @@ export async function createAgentMcpServer(options) {
605
605
  askedAt,
606
606
  })), resolvedMode);
607
607
  }
608
+ applyDecisionExpiry(next, existing);
608
609
  next.selfReportedAt = new Date().toISOString();
609
610
  await agentManager.updateAgentMetadata(callerAgentId, { digest: next });
610
611
  return {
@@ -612,6 +613,67 @@ export async function createAgentMcpServer(options) {
612
613
  structuredContent: ensureValidJson({ ok: true, digest: next }),
613
614
  };
614
615
  });
616
+ server.registerTool("resolve_decision", {
617
+ title: "Resolve a decision you already settled yourself",
618
+ description: "Close one of YOUR OWN pendingDecisions that no longer needs the user, because you " +
619
+ 'resolved it in the work itself. Recorded as answeredBy: "agent", which is a ' +
620
+ "different fact from the user answering and stays visibly different forever. " +
621
+ "REQUIRES a note saying how it was settled: the user is being told they no longer " +
622
+ "have to decide, so they are owed the reason. Pass optionId when what you did " +
623
+ "matches one of the options you listed; omit it when the work went another way and " +
624
+ "let the note carry it. Refuses a decision the USER already answered - their answer " +
625
+ "stands and you read it with get_session_digest. Use this the moment a question " +
626
+ "stops being open, not at the end of the session.",
627
+ inputSchema: {
628
+ decisionId: z.string().min(1).max(80),
629
+ note: z.string().min(1).max(400),
630
+ optionId: z.string().min(1).max(80).optional(),
631
+ },
632
+ outputSchema: {
633
+ ok: z.boolean(),
634
+ decision: PendingDecisionSchema.optional(),
635
+ error: z.string().optional(),
636
+ },
637
+ }, async ({ decisionId, note, optionId }) => {
638
+ if (!callerAgentId) {
639
+ throw new Error("resolve_decision is only available to agent-scoped MCP sessions");
640
+ }
641
+ const existing = agentManager.getAgent(callerAgentId)?.digest;
642
+ const result = answerPendingDecision(existing?.pendingDecisions, {
643
+ decisionId,
644
+ optionId: optionId ?? null,
645
+ note,
646
+ at: new Date().toISOString(),
647
+ by: "agent",
648
+ });
649
+ if (!result.ok) {
650
+ /*
651
+ * Returned, not thrown. "Already answered" and "no such decision" are
652
+ * both ordinary outcomes of an agent sweeping its own open questions,
653
+ * and a thrown error reads to the model as a broken tool worth
654
+ * retrying. It is not: it is the answer.
655
+ */
656
+ return {
657
+ content: [],
658
+ structuredContent: ensureValidJson({ ok: false, error: result.error }),
659
+ };
660
+ }
661
+ const next = { ...existing, pendingDecisions: result.decisions };
662
+ /*
663
+ * This writer ages decisions too. It is the third digest write path and
664
+ * was the only one not doing it, which quietly broke the invariant
665
+ * `applyDecisionExpiry`'s own docblock states. An agent sweeping its open
666
+ * list is exactly when a sibling decision is most likely to be past the
667
+ * TTL, so skipping it here costs a whole idle cycle for no reason.
668
+ */
669
+ applyDecisionExpiry(next, existing);
670
+ next.selfReportedAt = new Date().toISOString();
671
+ await agentManager.updateAgentMetadata(callerAgentId, { digest: next });
672
+ return {
673
+ content: [],
674
+ structuredContent: ensureValidJson({ ok: true, decision: result.decision }),
675
+ };
676
+ });
615
677
  return server;
616
678
  }
617
679
  function unionStrings(existing, incoming) {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js";
3
3
  import { resolveStructuredGenerationProviders, } from "./structured-generation-providers.js";
4
- import { buildDigestSourceText, deriveKeyFilesFromTimeline, mergeDigest, } from "./session-digest.js";
4
+ import { applyDecisionExpiry, buildDigestSourceText, deriveKeyFilesFromTimeline, mergeDigest, } from "./session-digest.js";
5
5
  const SUMMARY_MAX_CHARS = 600;
6
6
  const ACCOMPLISHMENT_MAX = 10;
7
7
  const NEXT_STEP_MAX_CHARS = 240;
@@ -200,7 +200,6 @@ export async function generateAndApplySessionDigest(options) {
200
200
  }
201
201
  // Deterministic, no LLM — always safe to refresh.
202
202
  const keyFiles = deriveKeyFilesFromTimeline(rows);
203
- const existing = agentManager.getAgent(agentId)?.digest;
204
203
  let summaryPatch = {};
205
204
  const sourceText = buildDigestSourceText(rows);
206
205
  if (sourceText.trim().length > 0) {
@@ -263,11 +262,43 @@ export async function generateAndApplySessionDigest(options) {
263
262
  // Fall through — still persist the deterministic keyFiles + timestamp.
264
263
  }
265
264
  }
265
+ /*
266
+ * Read the digest HERE, after the await above, not before it.
267
+ *
268
+ * It used to be read alongside `keyFiles`, then survive an LLM call capped at
269
+ * `DIGEST_GENERATION_TIMEOUT_MS` (120s), and `setDigest` replaces the digest
270
+ * wholesale, so this is a read-modify-write across a two-minute window. A user
271
+ * answering a decision inside that window had their answer written straight
272
+ * back out.
273
+ *
274
+ * On master that showed up as the entry reverting to unanswered: visibly
275
+ * wrong, and the user just answers again. With decision expiry it stops being
276
+ * recoverable, because the stale entry is still unanswered and a decision past
277
+ * the TTL gets stamped `answeredBy: "timeout"` - a terminal state
278
+ * `answerPendingDecision` refuses to overwrite. The card would then say
279
+ * "Expired unanswered" about a question they had answered, permanently.
280
+ *
281
+ * Nothing between the two points needs the old value: `existing` had exactly
282
+ * two uses, both below.
283
+ */
284
+ const existing = agentManager.getAgent(agentId)?.digest;
266
285
  const next = mergeDigest(existing, {
267
286
  keyFiles,
268
287
  generatedAt: new Date().toISOString(),
269
288
  ...summaryPatch,
270
289
  });
290
+ /*
291
+ * The idle sweep is where a QUIET agent's decisions age out.
292
+ *
293
+ * `set_session_digest` expires them too, but an agent that has stopped
294
+ * self-reporting never calls it, and stopping is the failing case: the
295
+ * decision this whole mechanism exists for was one the agent answered in its
296
+ * own code and then never mentioned again. This runs on every running->idle
297
+ * transition, which that agent still makes, and it rewrites the record the
298
+ * reel actually reads - so without it a 72h-old question stays on the card
299
+ * forever no matter what the TTL says.
300
+ */
301
+ applyDecisionExpiry(next, existing);
271
302
  await agentManager.updateAgentMetadata(agentId, { digest: next });
272
303
  }
273
304
  /**
@@ -38,6 +38,77 @@ export declare function isDigestEmpty(digest: SessionDigest | undefined): boolea
38
38
  * silently drops something still waiting on the user.
39
39
  */
40
40
  export declare const MAX_PENDING_DECISIONS = 20;
41
+ /**
42
+ * How long an UNANSWERED decision stays open before it expires.
43
+ *
44
+ * Three hooks write decisions onto a digest (the protocol injector, the Stop
45
+ * capture net, the per-turn nudge) and, until this existed, nothing ever took
46
+ * one off. `capDecisions` reasons about answered-vs-unanswered and has no time
47
+ * dimension at all, and it protects unanswered entries hardest, so a `blocking`
48
+ * question the agent then answered ITSELF in code stayed on the digest until 20
49
+ * newer decisions pushed it out. Measured: `reel-tail-depth` was asked, built,
50
+ * shipped twice and released, and was still presented to the user as open 31
51
+ * hours later.
52
+ *
53
+ * 72h rather than 24h on purpose. Expiring a question the user is still
54
+ * thinking about is worse than carrying a dead one an extra day: the first
55
+ * loses a real decision, the second costs a row.
56
+ */
57
+ export declare const DECISION_STALE_AFTER_MS: number;
58
+ /**
59
+ * Close out unanswered decisions older than the TTL, as `answeredBy: "timeout"`.
60
+ *
61
+ * NOT deleted. `answeredBy` already carried `"timeout"` as a value that nothing
62
+ * had ever written, which is the affordance this needs: the entry stays
63
+ * readable with no `chosenOptionId`, so "nobody ever answered" stays
64
+ * distinguishable from "answered, and this is the choice". Deleting would make
65
+ * a dropped question indistinguishable from one that never existed, which is
66
+ * the same class of silent loss the answer-stripping guard above prevents.
67
+ *
68
+ * Marking them answered also hands them to `capDecisions`, which already prunes
69
+ * answered entries oldest-first, so expiry needs no second eviction path.
70
+ *
71
+ * Daemon-side and after the merge, never on the incoming payload: `stripAnswer`
72
+ * means an agent cannot fake a timeout on its own question to make it go away.
73
+ *
74
+ * Deliberately NOT called from `mergePendingDecisions`. Wiring it in there made
75
+ * that pure, deterministic function depend on the wall clock, and five existing
76
+ * tests went red on fixtures whose `askedAt` was simply in the past. They were
77
+ * right: a merge that silently rewrites entries based on when it happens to run
78
+ * is not a merge. The write path owns the clock; the merge owns the data.
79
+ */
80
+ export declare function expireStaleDecisions(decisions: readonly PendingDecision[], now?: number, staleAfterMs?: number): PendingDecision[];
81
+ /**
82
+ * Age out decisions nobody answered, on EVERY digest write, from either writer.
83
+ *
84
+ * Not only when the agent sent `pendingDecisions`, which is the distinction the
85
+ * whole fix turns on: a decision goes stale precisely because the agent STOPPED
86
+ * mentioning it. `reel-tail-depth` was asked, answered by the agent in its own
87
+ * code, shipped and released, and never appeared in a payload again, so
88
+ * anything hanging off the merge would have run zero times on it. It was still
89
+ * shown to the user as an open blocking question 31 hours later.
90
+ *
91
+ * Reads the carried list when this write did not touch decisions, so a digest
92
+ * that only refreshes `nextStep` still ages them.
93
+ *
94
+ * Called from BOTH digest writers, because either one alone leaves the hole
95
+ * open at its own end:
96
+ * - `set_session_digest` (mcp-server.ts) is the agent reporting. An agent
97
+ * that has gone quiet does not call it, and quiet is the failing case.
98
+ * - `generateAndApplySessionDigest` (session-digest-generator.ts) runs on
99
+ * every running->idle transition, which is exactly what a quiet agent still
100
+ * does. Without it an agent that stops self-reporting keeps its stale
101
+ * decision on the card forever, TTL or no TTL, because nothing rewrites the
102
+ * record the reel reads.
103
+ *
104
+ * A helper rather than two inline blocks: the MCP handler was already at
105
+ * oxlint's complexity ceiling and one `if` tipped it to 21.
106
+ */
107
+ export declare function applyDecisionExpiry(next: {
108
+ pendingDecisions?: PendingDecision[];
109
+ }, existing: {
110
+ pendingDecisions?: PendingDecision[];
111
+ } | undefined): void;
41
112
  /**
42
113
  * Merge agent-reported decisions onto the stored list, upserting by `id`.
43
114
  *
@@ -128,6 +128,98 @@ function carryAnswerForward(incoming, existing) {
128
128
  ...(existing.answeredBy !== undefined ? { answeredBy: existing.answeredBy } : {}),
129
129
  };
130
130
  }
131
+ /**
132
+ * How long an UNANSWERED decision stays open before it expires.
133
+ *
134
+ * Three hooks write decisions onto a digest (the protocol injector, the Stop
135
+ * capture net, the per-turn nudge) and, until this existed, nothing ever took
136
+ * one off. `capDecisions` reasons about answered-vs-unanswered and has no time
137
+ * dimension at all, and it protects unanswered entries hardest, so a `blocking`
138
+ * question the agent then answered ITSELF in code stayed on the digest until 20
139
+ * newer decisions pushed it out. Measured: `reel-tail-depth` was asked, built,
140
+ * shipped twice and released, and was still presented to the user as open 31
141
+ * hours later.
142
+ *
143
+ * 72h rather than 24h on purpose. Expiring a question the user is still
144
+ * thinking about is worse than carrying a dead one an extra day: the first
145
+ * loses a real decision, the second costs a row.
146
+ */
147
+ export const DECISION_STALE_AFTER_MS = 72 * 60 * 60 * 1000;
148
+ /**
149
+ * Close out unanswered decisions older than the TTL, as `answeredBy: "timeout"`.
150
+ *
151
+ * NOT deleted. `answeredBy` already carried `"timeout"` as a value that nothing
152
+ * had ever written, which is the affordance this needs: the entry stays
153
+ * readable with no `chosenOptionId`, so "nobody ever answered" stays
154
+ * distinguishable from "answered, and this is the choice". Deleting would make
155
+ * a dropped question indistinguishable from one that never existed, which is
156
+ * the same class of silent loss the answer-stripping guard above prevents.
157
+ *
158
+ * Marking them answered also hands them to `capDecisions`, which already prunes
159
+ * answered entries oldest-first, so expiry needs no second eviction path.
160
+ *
161
+ * Daemon-side and after the merge, never on the incoming payload: `stripAnswer`
162
+ * means an agent cannot fake a timeout on its own question to make it go away.
163
+ *
164
+ * Deliberately NOT called from `mergePendingDecisions`. Wiring it in there made
165
+ * that pure, deterministic function depend on the wall clock, and five existing
166
+ * tests went red on fixtures whose `askedAt` was simply in the past. They were
167
+ * right: a merge that silently rewrites entries based on when it happens to run
168
+ * is not a merge. The write path owns the clock; the merge owns the data.
169
+ */
170
+ export function expireStaleDecisions(decisions, now = Date.now(), staleAfterMs = DECISION_STALE_AFTER_MS) {
171
+ return decisions.map((decision) => {
172
+ if (decision.answeredAt) {
173
+ return decision;
174
+ }
175
+ const askedAt = Date.parse(decision.askedAt);
176
+ /*
177
+ * An unparseable `askedAt` is left alone rather than treated as epoch 0,
178
+ * which would expire it instantly. A question with a broken timestamp is
179
+ * still a question.
180
+ */
181
+ if (Number.isNaN(askedAt) || now - askedAt < staleAfterMs) {
182
+ return decision;
183
+ }
184
+ return {
185
+ ...decision,
186
+ answeredAt: new Date(now).toISOString(),
187
+ answeredBy: "timeout",
188
+ };
189
+ });
190
+ }
191
+ /**
192
+ * Age out decisions nobody answered, on EVERY digest write, from either writer.
193
+ *
194
+ * Not only when the agent sent `pendingDecisions`, which is the distinction the
195
+ * whole fix turns on: a decision goes stale precisely because the agent STOPPED
196
+ * mentioning it. `reel-tail-depth` was asked, answered by the agent in its own
197
+ * code, shipped and released, and never appeared in a payload again, so
198
+ * anything hanging off the merge would have run zero times on it. It was still
199
+ * shown to the user as an open blocking question 31 hours later.
200
+ *
201
+ * Reads the carried list when this write did not touch decisions, so a digest
202
+ * that only refreshes `nextStep` still ages them.
203
+ *
204
+ * Called from BOTH digest writers, because either one alone leaves the hole
205
+ * open at its own end:
206
+ * - `set_session_digest` (mcp-server.ts) is the agent reporting. An agent
207
+ * that has gone quiet does not call it, and quiet is the failing case.
208
+ * - `generateAndApplySessionDigest` (session-digest-generator.ts) runs on
209
+ * every running->idle transition, which is exactly what a quiet agent still
210
+ * does. Without it an agent that stops self-reporting keeps its stale
211
+ * decision on the card forever, TTL or no TTL, because nothing rewrites the
212
+ * record the reel reads.
213
+ *
214
+ * A helper rather than two inline blocks: the MCP handler was already at
215
+ * oxlint's complexity ceiling and one `if` tipped it to 21.
216
+ */
217
+ export function applyDecisionExpiry(next, existing) {
218
+ const carried = next.pendingDecisions ?? existing?.pendingDecisions;
219
+ if (carried?.length) {
220
+ next.pendingDecisions = expireStaleDecisions(carried);
221
+ }
222
+ }
131
223
  /**
132
224
  * Trim to MAX_PENDING_DECISIONS, dropping ANSWERED entries oldest-first. An
133
225
  * unanswered decision is only dropped when the list is entirely unanswered and
@@ -168,7 +260,31 @@ export function mergePendingDecisions(existing, incoming, mode = "merge") {
168
260
  for (const decision of existing ?? []) {
169
261
  byId.set(decision.id, decision);
170
262
  }
171
- const out = mode === "replace" ? [] : [...(existing ?? [])];
263
+ /*
264
+ * `replace` replaces the agent's OPEN questions, never the user's answers.
265
+ *
266
+ * It used to start from `[]`, so an answered entry simply left out of the
267
+ * payload was dropped with its `chosenOptionId`, `answerNote` and
268
+ * `answeredBy` - the only place that choice lives. On its own that was
269
+ * "merely" erasure, and the question came back visibly open.
270
+ *
271
+ * `resolve_decision` turns it into forgery in three calls. Drop the answered
272
+ * entry with a `replace` that omits it; re-report the same id, which now has
273
+ * no stored `previous`, so `carryAnswerForward` never runs and it is stored
274
+ * unanswered; then `resolve_decision` sees no `answeredAt`, its guard does not
275
+ * fire, and the agent writes its OWN choice where the user's had been. The
276
+ * feed then shows a different answer, attributed to the agent, with no record
277
+ * that the user ever decided.
278
+ *
279
+ * Keeping answered entries unconditionally closes that at the source, for
280
+ * both tools at once. It costs nothing: `capDecisions` already evicts answered
281
+ * entries oldest-first, so they still age out, and an incoming id that matches
282
+ * one is found by the `findIndex` below and runs through `carryAnswerForward`
283
+ * exactly as in merge mode.
284
+ */
285
+ const out = mode === "replace"
286
+ ? (existing ?? []).filter((decision) => decision.answeredAt)
287
+ : [...(existing ?? [])];
172
288
  for (const raw of incoming) {
173
289
  const sanitized = stripAnswer(raw);
174
290
  const previous = byId.get(raw.id);
@@ -8,6 +8,7 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js";
8
8
  import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
9
9
  import type { JudgeRelayGate } from "../judge-relay-gate.js";
10
10
  import type { ScheduleService } from "../../schedule/service.js";
11
+ import type { SubscriptionReader } from "../../ingestion/subscriptions/reader.js";
11
12
  import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
12
13
  import type { GitHubService } from "../../../services/github-service.js";
13
14
  import type { WorkspaceGitService } from "../../workspace-git-service.js";
@@ -20,6 +21,14 @@ export interface PaseoToolHostDependencies {
20
21
  terminalManager?: TerminalManager | null;
21
22
  getDaemonTcpPort?: () => number | null;
22
23
  scheduleService?: ScheduleService | null;
24
+ /**
25
+ * Read access to session subscriptions and the items they have announced.
26
+ *
27
+ * Supplied as two narrow stores rather than a service: these tools only ever
28
+ * READ, and handing the catalog something that could arm or notify would put
29
+ * the delivery policy inside a model's reach.
30
+ */
31
+ subscriptionReader?: SubscriptionReader | null;
23
32
  providerSnapshotManager: ProviderSnapshotManager;
24
33
  github?: GitHubService;
25
34
  workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot">;
@@ -257,7 +257,7 @@ function resolveTerminalKeyToken(key, literal) {
257
257
  }
258
258
  }
259
259
  export function createPaseoToolCatalog(options) {
260
- const { agentManager, agentStorage, terminalManager, scheduleService, providerSnapshotManager, callerAgentId, resolveSpeakHandler, resolveCallerContext, logger, } = options;
260
+ const { agentManager, agentStorage, terminalManager, scheduleService, subscriptionReader, providerSnapshotManager, callerAgentId, resolveSpeakHandler, resolveCallerContext, logger, } = options;
261
261
  const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" });
262
262
  const waitTracker = new WaitForAgentTracker(logger);
263
263
  const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null;
@@ -2060,6 +2060,81 @@ export function createPaseoToolCatalog(options) {
2060
2060
  structuredContent: ensureValidJson(schedule),
2061
2061
  };
2062
2062
  });
2063
+ registerTool("list_subscriptions", {
2064
+ title: "List subscriptions",
2065
+ description: "List this daemon's source subscriptions: which filter each one listens to, which agent it notifies, and when it last did.",
2066
+ inputSchema: {},
2067
+ outputSchema: {
2068
+ subscriptions: z.array(z.object({
2069
+ id: z.string(),
2070
+ filterId: z.string(),
2071
+ agentId: z.string(),
2072
+ label: z.string().nullable(),
2073
+ status: z.string(),
2074
+ debounceMs: z.number(),
2075
+ lastNotifiedAt: z.string().nullable(),
2076
+ })),
2077
+ },
2078
+ }, async () => {
2079
+ if (!subscriptionReader) {
2080
+ throw new Error("Subscriptions are not configured on this daemon");
2081
+ }
2082
+ const subscriptions = (await subscriptionReader.list(callerAgentId)).map((subscription) => ({
2083
+ id: subscription.id,
2084
+ filterId: subscription.filterId,
2085
+ agentId: subscription.agentId,
2086
+ label: subscription.label,
2087
+ status: subscription.status,
2088
+ debounceMs: subscription.debounceMs,
2089
+ lastNotifiedAt: subscription.lastNotifiedAt,
2090
+ }));
2091
+ return { content: [], structuredContent: ensureValidJson({ subscriptions }) };
2092
+ });
2093
+ registerTool("get_subscription_items", {
2094
+ title: "Get subscription items",
2095
+ description: "Full content of items a subscription notification announced. Pass the subscription id and the item keys from the notice. Keys that are no longer held come back absent rather than as an error.",
2096
+ inputSchema: {
2097
+ subscriptionId: z.string().min(1),
2098
+ itemKeys: z.array(z.string().min(1)).min(1).max(50),
2099
+ },
2100
+ outputSchema: {
2101
+ items: z.array(z.object({
2102
+ itemKey: z.string(),
2103
+ contentHash: z.string(),
2104
+ title: z.string(),
2105
+ subtitle: z.string(),
2106
+ timestampMs: z.number(),
2107
+ notifiedAt: z.string().nullable(),
2108
+ payload: z.unknown(),
2109
+ })),
2110
+ missingKeys: z.array(z.string()),
2111
+ },
2112
+ }, async (input) => {
2113
+ if (!subscriptionReader) {
2114
+ throw new Error("Subscriptions are not configured on this daemon");
2115
+ }
2116
+ const items = await subscriptionReader.resolveItems(input.subscriptionId, input.itemKeys, callerAgentId);
2117
+ const found = new Set(items.map((item) => item.itemKey));
2118
+ // Named, not silent. A key that is gone is an ordinary consequence of the
2119
+ // TTL sweep, and saying which ones went is what stops a caller reading a
2120
+ // short list as the whole answer.
2121
+ const missingKeys = input.itemKeys.filter((key) => !found.has(key));
2122
+ return {
2123
+ content: [],
2124
+ structuredContent: ensureValidJson({
2125
+ items: items.map((item) => ({
2126
+ itemKey: item.itemKey,
2127
+ contentHash: item.contentHash,
2128
+ title: item.title,
2129
+ subtitle: item.subtitle,
2130
+ timestampMs: item.timestampMs,
2131
+ notifiedAt: item.notifiedAt,
2132
+ payload: item.payload,
2133
+ })),
2134
+ missingKeys,
2135
+ }),
2136
+ };
2137
+ });
2063
2138
  registerTool("list_schedules", {
2064
2139
  title: "List schedules",
2065
2140
  description: "List all schedules managed by the daemon.",
@@ -45,6 +45,13 @@
45
45
  * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
46
46
  * - `rename_workspace` mutates.
47
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
48
+ * - `get_subscription_items` a FETCHER, not a passive read: it pulls fresh
49
+ * untrusted bytes (raw aggregator event bodies) into
50
+ * the judge's context on demand, widening the very
51
+ * input surface this allowlist bounds. The judge is
52
+ * also the one identity holding an outbound relay
53
+ * channel, and read-only stops mutation, not
54
+ * exfiltration through a channel granted on purpose.
48
55
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
49
56
  * that blocks holds its run open indefinitely.
50
57
  */
@@ -45,6 +45,13 @@
45
45
  * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
46
46
  * - `rename_workspace` mutates.
47
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
48
+ * - `get_subscription_items` a FETCHER, not a passive read: it pulls fresh
49
+ * untrusted bytes (raw aggregator event bodies) into
50
+ * the judge's context on demand, widening the very
51
+ * input surface this allowlist bounds. The judge is
52
+ * also the one identity holding an outbound relay
53
+ * channel, and read-only stops mutation, not
54
+ * exfiltration through a channel granted on purpose.
48
55
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
49
56
  * that blocks holds its run open indefinitely.
50
57
  */
@@ -59,6 +66,7 @@ export const READ_ONLY_PASEO_TOOLS = new Set([
59
66
  "list_pending_permissions",
60
67
  "list_providers",
61
68
  "list_schedules",
69
+ "list_subscriptions",
62
70
  "list_terminals",
63
71
  "list_worktrees",
64
72
  "schedule_logs",
@@ -119,6 +119,12 @@ import { ScheduleService } from "./schedule/service.js";
119
119
  import { IngestionService } from "./ingestion/service.js";
120
120
  import { FilterStore, filtersDir } from "./ingestion/filters/store.js";
121
121
  import { SourceStore } from "./ingestion/sources/store.js";
122
+ import { formatSystemNotificationPrompt, sendPromptToAgent } from "./agent/agent-prompt.js";
123
+ import { SubscriptionStore } from "./ingestion/subscriptions/store.js";
124
+ import { PendingItemStore } from "./ingestion/subscriptions/pending-store.js";
125
+ import { SubscriptionNotifier } from "./ingestion/subscriptions/notifier.js";
126
+ import { SubscriptionPoller } from "./ingestion/subscriptions/poller.js";
127
+ import { createSubscriptionReader } from "./ingestion/subscriptions/reader.js";
122
128
  import { resolveSourceGateway } from "./ingestion/adapters.js";
123
129
  import { CapLimiter } from "./ingestion/cap-limiter.js";
124
130
  import { IngestionLedger } from "./ingestion/ledger.js";
@@ -443,6 +449,11 @@ function buildIngestionCollaborators(config, ingestionLedger) {
443
449
  const ingestionFilterStore = new FilterStore(filtersDir(config.paseoHome));
444
450
  const ingestionSourceStore = new SourceStore(path.join(config.paseoHome, "sources"));
445
451
  const ingestionGatewayResolver = resolveIngestionGatewayResolver(config, ingestionConfig);
452
+ // Session subscriptions. Declared beside the other ingestion stores so the
453
+ // schedule service below can be handed a live filter resolver rather than the
454
+ // thrower it has been defaulting to since the source cadence was added.
455
+ const ingestionSubscriptionStore = new SubscriptionStore(path.join(config.paseoHome, "subscriptions"));
456
+ const ingestionPendingStore = new PendingItemStore(path.join(config.paseoHome, "subscription-items"));
446
457
  return {
447
458
  capLimiter,
448
459
  ingestionCohortStore,
@@ -450,6 +461,8 @@ function buildIngestionCollaborators(config, ingestionLedger) {
450
461
  ingestionFilterStore,
451
462
  ingestionSourceStore,
452
463
  ingestionGatewayResolver,
464
+ ingestionSubscriptionStore,
465
+ ingestionPendingStore,
453
466
  };
454
467
  }
455
468
  export async function createPaseoDaemon(config, rootLogger) {
@@ -936,7 +949,7 @@ export async function createPaseoDaemon(config, rootLogger) {
936
949
  // from being true (someone caches `used` in a field to save a query). Two
937
950
  // guards, one of which fails loudly.
938
951
  //
939
- const { capLimiter, ingestionCohortStore, ingestionCohorts, ingestionFilterStore, ingestionSourceStore, ingestionGatewayResolver, } = buildIngestionCollaborators(config, ingestionLedger);
952
+ const { capLimiter, ingestionCohortStore, ingestionCohorts, ingestionFilterStore, ingestionSourceStore, ingestionGatewayResolver, ingestionSubscriptionStore, ingestionPendingStore, } = buildIngestionCollaborators(config, ingestionLedger);
940
953
  // Explicit annotation breaks the inference cycle: this options object passes
941
954
  // `schedule: () => scheduleService`, so inferring the type from the initializer
942
955
  // is circular (TS7022/TS7023). The lazy closure itself is correct and intended.
@@ -992,6 +1005,62 @@ export async function createPaseoDaemon(config, rootLogger) {
992
1005
  });
993
1006
  await ingestionService.start();
994
1007
  logger.info({ elapsed: elapsed() }, "Ingestion service initialized");
1008
+ // Session subscriptions: source change -> thin notice -> one specific session.
1009
+ //
1010
+ // It owns its own timer and never touches the schedule engine. A subscription
1011
+ // has to survive its target being archived, and `sweepOrphanedSchedules`
1012
+ // completes an agent-target schedule terminally at the next boot, which is the
1013
+ // exact opposite of the rule here: sending to a dead session revives it.
1014
+ const subscriptionNotifier = new SubscriptionNotifier({
1015
+ subscriptions: ingestionSubscriptionStore,
1016
+ pending: ingestionPendingStore,
1017
+ agentManager,
1018
+ filterResolver: (filterId) => ingestionFilterStore.get(filterId),
1019
+ // `sendPromptToAgent` is the one entry point where unarchive, reload and
1020
+ // detached-runtime recycling already live, which is what makes "the session
1021
+ // revives" true rather than aspirational. The `route` it returns is READ
1022
+ // here on purpose: the websocket handler discards it and answers
1023
+ // `accepted: true` even for `no-route`, and a subscription that believed
1024
+ // that would mark items announced that nobody ever saw.
1025
+ send: async ({ agentId, body }) => {
1026
+ const result = await sendPromptToAgent({
1027
+ agentManager,
1028
+ agentStorage,
1029
+ agentId,
1030
+ prompt: formatSystemNotificationPrompt(body),
1031
+ // A notice must not resurrect a session the user deliberately archived.
1032
+ unarchive: false,
1033
+ logger,
1034
+ });
1035
+ // Fail CLOSED on a missing route. `sendPromptToAgent` types `route` as
1036
+ // optional and omits it on exactly one branch: an archived agent when
1037
+ // `unarchive` is false (`agent-prompt.ts:291-295`), which is the branch
1038
+ // this caller always takes. Coalescing that to "live" would mark every
1039
+ // waiting item announced, advance `lastNotifiedAt` and log a delivery,
1040
+ // for a prompt that was never written anywhere.
1041
+ return { route: result.route ?? "no-route" };
1042
+ },
1043
+ logger,
1044
+ });
1045
+ const subscriptionPoller = new SubscriptionPoller({
1046
+ subscriptions: ingestionSubscriptionStore,
1047
+ pending: ingestionPendingStore,
1048
+ notifier: subscriptionNotifier,
1049
+ sourceStore: ingestionSourceStore,
1050
+ filterResolver: (filterId) => ingestionFilterStore.get(filterId),
1051
+ gatewayResolver: ingestionGatewayResolver,
1052
+ logger,
1053
+ });
1054
+ const subscriptionReader = createSubscriptionReader({
1055
+ subscriptions: ingestionSubscriptionStore,
1056
+ pending: ingestionPendingStore,
1057
+ });
1058
+ // The retention half of storing payloads at rest. At boot, beside the other
1059
+ // sweeps, rather than on a timer of its own.
1060
+ await subscriptionPoller.sweep().catch((error) => {
1061
+ logger.warn({ err: error }, "Subscription payload sweep failed");
1062
+ });
1063
+ subscriptionPoller.start();
995
1064
  // The read-only ingestion PREVIEW collaborators (Story 5.1).
996
1065
  //
997
1066
  // Wired beside IngestionService rather than through it, because that service
@@ -1164,6 +1233,7 @@ export async function createPaseoDaemon(config, rootLogger) {
1164
1233
  terminalManager,
1165
1234
  getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
1166
1235
  scheduleService,
1236
+ subscriptionReader,
1167
1237
  providerSnapshotManager,
1168
1238
  github,
1169
1239
  workspaceGitService,
@@ -1,6 +1,8 @@
1
1
  export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js";
2
2
  export { loadConfig, type CliConfigOverrides } from "./config.js";
3
3
  export { resolvePaseoHome } from "./paseo-home.js";
4
+ export { SubscriptionStore } from "./ingestion/subscriptions/store.js";
5
+ export { PendingItemStore } from "./ingestion/subscriptions/pending-store.js";
4
6
  export { getOrCreateServerId } from "./server-id.js";
5
7
  export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
6
8
  export { loadPersistedConfig, savePersistedConfig, type PersistedConfig, } from "./persisted-config.js";
@@ -2,6 +2,12 @@
2
2
  export { createPaseoDaemon } from "./bootstrap.js";
3
3
  export { loadConfig } from "./config.js";
4
4
  export { resolvePaseoHome } from "./paseo-home.js";
5
+ // Session subscriptions. Exported for the CLI, which writes the store DIRECTLY
6
+ // rather than through the daemon: both stores re-read from disk on every call
7
+ // and cache nothing, so a file written here is picked up by the poller's next
8
+ // tick with no RPC and no restart.
9
+ export { SubscriptionStore } from "./ingestion/subscriptions/store.js";
10
+ export { PendingItemStore } from "./ingestion/subscriptions/pending-store.js";
5
11
  export { getOrCreateServerId } from "./server-id.js";
6
12
  export { createRootLogger } from "./logger.js";
7
13
  export { loadPersistedConfig, savePersistedConfig, } from "./persisted-config.js";