@hyperdrive.bot/paseo-server 0.3.10 → 0.3.11

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/agent-loading.js +24 -3
  2. package/dist/server/server/agent/agent-manager.d.ts +22 -0
  3. package/dist/server/server/agent/agent-manager.js +36 -0
  4. package/dist/server/server/agent/agent-storage.d.ts +9 -0
  5. package/dist/server/server/agent/blocker-log.d.ts +81 -0
  6. package/dist/server/server/agent/blocker-log.js +113 -0
  7. package/dist/server/server/agent/card-move-log.d.ts +66 -0
  8. package/dist/server/server/agent/card-move-log.js +113 -0
  9. package/dist/server/server/agent/jarvis-judge.d.ts +121 -0
  10. package/dist/server/server/agent/jarvis-judge.js +225 -0
  11. package/dist/server/server/agent/judge-relay-gate.d.ts +70 -0
  12. package/dist/server/server/agent/judge-relay-gate.js +92 -0
  13. package/dist/server/server/agent/mcp-server.js +9 -0
  14. package/dist/server/server/agent/mcp-shared.d.ts +5 -0
  15. package/dist/server/server/agent/session-digest-generator.d.ts +27 -0
  16. package/dist/server/server/agent/session-digest-generator.js +208 -17
  17. package/dist/server/server/agent/tools/paseo-tools.d.ts +12 -0
  18. package/dist/server/server/agent/tools/paseo-tools.js +48 -0
  19. package/dist/server/server/agent/tools/read-only-surface.d.ts +52 -0
  20. package/dist/server/server/agent/tools/read-only-surface.js +68 -0
  21. package/dist/server/server/agent/tools/types.d.ts +5 -0
  22. package/dist/server/server/bootstrap.js +73 -0
  23. package/dist/server/web-ui/_expo/static/js/web/{index-461cf3ab1d54d6debea0e037829768e9.js → index-5a54792f78b8475572ccfac9808dd926.js} +6 -6
  24. package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.br +0 -0
  25. package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.gz +0 -0
  26. package/dist/server/web-ui/_expo/static/js/web/{index-461cf3ab1d54d6debea0e037829768e9.js.map.br → index-5a54792f78b8475572ccfac9808dd926.js.map.br} +0 -0
  27. package/dist/server/web-ui/_expo/static/js/web/{index-461cf3ab1d54d6debea0e037829768e9.js.map.gz → index-5a54792f78b8475572ccfac9808dd926.js.map.gz} +0 -0
  28. package/dist/server/web-ui/index.html +1 -1
  29. package/dist/server/web-ui/index.html.br +0 -0
  30. package/dist/server/web-ui/index.html.gz +0 -0
  31. package/package.json +6 -6
  32. package/dist/server/web-ui/_expo/static/js/web/index-461cf3ab1d54d6debea0e037829768e9.js.br +0 -0
  33. package/dist/server/web-ui/_expo/static/js/web/index-461cf3ab1d54d6debea0e037829768e9.js.gz +0 -0
@@ -4,35 +4,200 @@ import { resolveStructuredGenerationProviders, } from "./structured-generation-p
4
4
  import { buildDigestSourceText, deriveKeyFilesFromTimeline, mergeDigest, } from "./session-digest.js";
5
5
  const SUMMARY_MAX_CHARS = 600;
6
6
  const ACCOMPLISHMENT_MAX = 10;
7
+ const NEXT_STEP_MAX_CHARS = 240;
8
+ const BLOCKER_MAX = 5;
7
9
  const DIGEST_SUMMARY_SCHEMA = z.object({
8
10
  summary: z.string().max(SUMMARY_MAX_CHARS),
9
11
  accomplishments: z.array(z.string()).max(ACCOMPLISHMENT_MAX),
12
+ // Jarvis substrate (JARVIS-V1-SPEC §3.1). These land in `derived*` digest keys,
13
+ // never in the self-reported `nextStep`/`blockers`.
14
+ // Nullish rather than required: partial substrate beats a whole generation
15
+ // failing schema validation and costing the summary too. Cheap insurance, and
16
+ // unlike a second generation call it costs no extra tokens.
17
+ nextStep: z.string().max(NEXT_STEP_MAX_CHARS).nullish(),
18
+ blockers: z.array(z.string()).max(BLOCKER_MAX).nullish(),
19
+ urgency: z.enum(["low", "normal", "high"]).nullish(),
20
+ deadline: z.string().nullish(),
10
21
  });
11
- // Debounce: `idle` fires on every pause for input, so cap LLM regeneration at
12
- // one pass per window per agent and never run two concurrently. keyFiles is
13
- // deterministic + cheap and refreshes on every call. See docs/session-digest.md §5.
14
- const DIGEST_DEBOUNCE_MS = 10 * 60 * 1000;
22
+ /**
23
+ * Refresh gating (JARVIS-V1-SPEC §3.1, hazard 1).
24
+ *
25
+ * The ORIGINAL gate was a flat 10-minute wall-clock debounce, stamped BEFORE the
26
+ * work ran. Two consequences made it unsafe once the digest became a one-shot,
27
+ * unrecoverable derivation rather than a nice-to-have summary:
28
+ *
29
+ * 1. Back-to-back idles inside the window were silently dropped, and a dropped
30
+ * derivation is not always recoverable (see the readability note below).
31
+ * 2. Stamping before the attempt meant a FAILED generation still burned the
32
+ * window, so the next idle was skipped too. That is "record intent as fact":
33
+ * the timestamp meant "we tried", not "we produced a digest".
34
+ *
35
+ * CORRECTION (measured 2026-07-31). An earlier version of this comment claimed
36
+ * the timeline is "destroyed shortly after a session goes cold". That is WRONG,
37
+ * and it was generalized from two probes at the extremes with nothing in between.
38
+ * What is actually true:
39
+ *
40
+ * - paseo stores NO timeline of its own. Agent records on disk are ~4 KB of
41
+ * pure metadata with no timeline key.
42
+ * - `get_agent_activity` reads THROUGH to the provider's own transcript, for
43
+ * Claude `~/.claude/projects/<slug>/<sid>.jsonl`. Readability is binary and
44
+ * age-independent: a 19.6-day-old session returned 88 activities in full.
45
+ * - Content is lost only when that provider file is pruned. For Claude that is
46
+ * `cleanupPeriodDays`, default 30, which runs at CLI startup on file mtime.
47
+ * - A second gate exists: the read fails outright when the agent's `cwd` no
48
+ * longer exists ("Working directory does not exist"), which affects sessions
49
+ * from removed worktrees or another machine regardless of file retention.
50
+ *
51
+ * So this derivation is NOT primarily insurance against a minutes-later loss.
52
+ * Its real value is (a) surviving the provider's prune window, (b) surviving the
53
+ * cwd gate, (c) normalizing across providers whose stores paseo cannot read, and
54
+ * (d) cost: a few hundred bytes of digest instead of re-reading a transcript that
55
+ * can exceed 40 MB. Still worth doing on every idle, for different reasons than
56
+ * originally written.
57
+ *
58
+ * The replacement gates on CONTENT and nothing else: skip only when the timeline
59
+ * has not grown since the last SUCCESSFUL digest. New content is new information
60
+ * and is always worth deriving.
61
+ *
62
+ * There is deliberately NO wall-clock floor. An earlier draft had one, and it
63
+ * reintroduced exactly the bug being fixed: two idles a minute apart, both with
64
+ * new content, and the second silently dropped. Any clock-based gate re-creates
65
+ * that, because "we ran recently" and "nothing new happened" are different
66
+ * questions and only the second is a safe reason to skip a one-shot derivation.
67
+ *
68
+ * Cost note: on a chatty session this derives roughly once per completed turn
69
+ * instead of once per 10 minutes. That is the intended trade. The spec classifies
70
+ * a lost derivation as a correctness bug and an extra derivation as a cost line.
71
+ * If cost needs tuning later, the knob must stay content-based (for example
72
+ * "only derive when the timeline grew by more than N rows"), never clock-based.
73
+ */
74
+ /**
75
+ * Hard ceiling on one generation (found live 2026-07-31).
76
+ *
77
+ * `generateStructuredAgentResponseWithFallback` takes no signal and no timeout,
78
+ * and it CAN hang indefinitely: an internal generator agent spawned at 17:50:42
79
+ * and never settled, no success and no error, for the rest of the daemon's life.
80
+ *
81
+ * That is far worse than a slow digest. The hung promise never leaves `inFlight`,
82
+ * so the agent can never derive again, AND because the Jarvis judge is chained to
83
+ * `onSettled`, the judge goes permanently silent for that agent too. One stuck
84
+ * generation quietly removes a card from Jarvis forever, with nothing in the log.
85
+ *
86
+ * Racing a timeout turns that into an ordinary failure: counted, logged, mark not
87
+ * stamped so the next idle retries, and onSettled still fires so the judge runs
88
+ * with whatever substrate already exists. The abandoned work may keep running in
89
+ * the background; that is acceptable, recovering the caller is the point.
90
+ */
91
+ const DIGEST_GENERATION_TIMEOUT_MS = 120000;
92
+ class DigestGenerationTimeoutError extends Error {
93
+ constructor(ms) {
94
+ super(`Session digest generation exceeded ${ms}ms`);
95
+ this.name = "DigestGenerationTimeoutError";
96
+ }
97
+ }
98
+ function withTimeout(work, ms) {
99
+ let timer;
100
+ const bomb = new Promise((_resolve, reject) => {
101
+ timer = setTimeout(() => reject(new DigestGenerationTimeoutError(ms)), ms);
102
+ // Never let this timer keep the daemon alive on shutdown.
103
+ timer.unref?.();
104
+ });
105
+ return Promise.race([work, bomb]).finally(() => {
106
+ if (timer) {
107
+ clearTimeout(timer);
108
+ }
109
+ });
110
+ }
15
111
  const lastDigestAt = new Map();
112
+ /** agentId -> `${epoch}:${maxSeq}` of the last SUCCESSFUL digest. */
113
+ const lastDigestMark = new Map();
16
114
  const inFlight = new Set();
17
- function buildDigestPrompt(sourceText) {
115
+ /** Observability for a path whose failures are permanent (§3.1, hazard 2). */
116
+ const digestCounters = { attempted: 0, succeeded: 0, failed: 0, timedOut: 0, skippedUnchanged: 0 };
117
+ export function getSessionDigestCounters() {
118
+ return { ...digestCounters };
119
+ }
120
+ /** Test seam: the maps are module-level, so tests need a way back to a clean slate. */
121
+ export function resetSessionDigestStateForTests() {
122
+ lastDigestAt.clear();
123
+ lastDigestMark.clear();
124
+ inFlight.clear();
125
+ digestCounters.attempted = 0;
126
+ digestCounters.succeeded = 0;
127
+ digestCounters.failed = 0;
128
+ digestCounters.timedOut = 0;
129
+ digestCounters.skippedUnchanged = 0;
130
+ }
131
+ function buildDigestPrompt(sourceText, todayIso) {
18
132
  return [
19
133
  "You are summarizing a coding agent session so an orchestrator can understand it",
20
- "without reading the full transcript. Return JSON with exactly two fields:",
134
+ "without reading the full transcript. Return JSON with exactly these fields:",
21
135
  `- summary: one paragraph (<= ${SUMMARY_MAX_CHARS} chars) describing what this session`,
22
136
  " is doing and its current state.",
23
137
  `- accomplishments: up to ${ACCOMPLISHMENT_MAX} short strings, each a concrete thing done`,
24
138
  " (file changed, feature added, bug fixed). Be specific and factual; do not invent.",
139
+ `- nextStep: <= ${NEXT_STEP_MAX_CHARS} chars, the single concrete thing this session was`,
140
+ " about to do next, phrased as an action. Null if the work is finished or you cannot tell.",
141
+ `- blockers: up to ${BLOCKER_MAX} short strings, each something STOPPING progress. If the`,
142
+ " session is waiting on a specific person or an external party, name them explicitly",
143
+ ' (for example "waiting on Marco to sign the contract"). Empty array if nothing blocks it.',
144
+ "- urgency: one of low, normal, high. Judge from the work itself, not from how recent it is.",
145
+ " high = production is broken, a client is affected, or a dated commitment is at risk.",
146
+ " normal = ordinary in-flight work. low = exploration, cleanup, or already finished.",
147
+ `- deadline: a real-world calendar date named in the session as a commitment, in YYYY-MM-DD`,
148
+ ` form. Today is ${todayIso}, so resolve relative phrases like "Friday" against it. Null if`,
149
+ " no date is committed to. Do NOT invent one and do not infer a date from mere urgency.",
25
150
  "",
26
151
  "Session transcript:",
27
152
  sourceText,
28
153
  ].join("\n");
29
154
  }
155
+ /**
156
+ * `${epoch}:${maxSeq}` for the agent's current timeline.
157
+ *
158
+ * Row `seq` restarts at 1 whenever the timeline store is re-initialized without
159
+ * seeded rows, so a seq is ONLY comparable within an epoch. Comparing a bare seq
160
+ * across epochs makes a fresh timeline look older than the last digest and skips
161
+ * forever, silently. Found live: turn 2 of a session never re-derived because
162
+ * its post-resume seq was below the pre-resume high-water mark.
163
+ */
164
+ function resolveDigestMark(agentManager, agentId, rows) {
165
+ const maxSeq = rows.length > 0 ? rows[rows.length - 1].seq : 0;
166
+ try {
167
+ return `${agentManager.getTimelineEpoch(agentId)}:${maxSeq}`;
168
+ }
169
+ catch {
170
+ // Agent not resident: use a marker that can never match a stored one, so we
171
+ // derive rather than skip. Skipping is the dangerous direction.
172
+ return `unknown-${Date.now()}:${maxSeq}`;
173
+ }
174
+ }
175
+ function isUnchangedSinceLastDigest(agentId, mark) {
176
+ const seen = lastDigestMark.get(agentId);
177
+ if (seen === undefined) {
178
+ return false;
179
+ }
180
+ const sep = seen.lastIndexOf(":");
181
+ const markSep = mark.lastIndexOf(":");
182
+ const sameEpoch = seen.slice(0, sep) === mark.slice(0, markSep);
183
+ return sameEpoch && Number(mark.slice(markSep + 1)) <= Number(seen.slice(sep + 1));
184
+ }
30
185
  export async function generateAndApplySessionDigest(options) {
31
186
  const { agentManager, agentId, cwd, logger } = options;
32
187
  const rows = await agentManager.getTimelineRows(agentId);
33
188
  if (rows.length === 0) {
34
189
  return;
35
190
  }
191
+ // Content-based gate (see the lastDigestMark block comment above). Extracted
192
+ // to a helper to stay under the oxlint complexity ceiling of 20.
193
+ const mark = resolveDigestMark(agentManager, agentId, rows);
194
+ if (isUnchangedSinceLastDigest(agentId, mark)) {
195
+ digestCounters.skippedUnchanged += 1;
196
+ // INFO, not debug. Every bug in this file has been a silent skip; a skip
197
+ // nobody can see is how a dead feature looks healthy.
198
+ logger.info({ agentId, mark }, "Session digest skipped: timeline unchanged");
199
+ return;
200
+ }
36
201
  // Deterministic, no LLM — always safe to refresh.
37
202
  const keyFiles = deriveKeyFilesFromTimeline(rows);
38
203
  const existing = agentManager.getAgent(agentId)?.digest;
@@ -50,10 +215,11 @@ export async function generateAndApplySessionDigest(options) {
50
215
  currentSelection: options.currentSelection,
51
216
  })
52
217
  : [];
53
- const result = await generator({
218
+ digestCounters.attempted += 1;
219
+ const result = await withTimeout(generator({
54
220
  manager: agentManager,
55
221
  cwd,
56
- prompt: buildDigestPrompt(sourceText),
222
+ prompt: buildDigestPrompt(sourceText, new Date().toISOString().slice(0, 10)),
57
223
  schema: DIGEST_SUMMARY_SCHEMA,
58
224
  schemaName: "SessionDigestSummary",
59
225
  maxRetries: 2,
@@ -64,18 +230,36 @@ export async function generateAndApplySessionDigest(options) {
64
230
  title: "Session digest generator",
65
231
  internal: true,
66
232
  },
67
- });
233
+ }), options.timeoutMs ?? DIGEST_GENERATION_TIMEOUT_MS);
68
234
  summaryPatch = {
69
235
  summary: result.summary,
70
236
  accomplishments: result.accomplishments,
237
+ // Derived fields go in their own keys. Writing `nextStep`/`blockers`
238
+ // directly would clobber whatever the session self-reported, because
239
+ // mergeDigest overwrites any key present in the patch.
240
+ ...(result.nextStep ? { derivedNextStep: result.nextStep } : {}),
241
+ ...(result.blockers ? { derivedBlockers: result.blockers } : {}),
242
+ ...(result.urgency ? { urgency: result.urgency } : {}),
243
+ ...(result.deadline ? { derivedDeadline: result.deadline } : {}),
71
244
  };
245
+ // Stamp ONLY on success. A failure must leave the window open so the next
246
+ // idle retries: there is no catch-up job, and the source transcript is
247
+ // subject to the provider's own prune window and the cwd gate, so a
248
+ // derivation missed today may not be reproducible later.
249
+ lastDigestAt.set(agentId, Date.now());
250
+ lastDigestMark.set(agentId, mark);
251
+ digestCounters.succeeded += 1;
72
252
  }
73
253
  catch (error) {
254
+ digestCounters.failed += 1;
255
+ if (error instanceof DigestGenerationTimeoutError) {
256
+ digestCounters.timedOut += 1;
257
+ }
74
258
  const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
75
- logger.error({ err: error, agentId, attempts }, error instanceof StructuredAgentResponseError ||
259
+ logger.error({ err: error, agentId, attempts, failedTotal: digestCounters.failed }, error instanceof StructuredAgentResponseError ||
76
260
  error instanceof StructuredAgentFallbackError
77
- ? "Session digest summary generation failed"
78
- : "Session digest generation failed");
261
+ ? "Session digest summary generation failed (derivation lost for this idle; will retry on next idle)"
262
+ : "Session digest generation failed (derivation lost for this idle; will retry on next idle)");
79
263
  // Fall through — still persist the deterministic keyFiles + timestamp.
80
264
  }
81
265
  }
@@ -96,11 +280,9 @@ export function scheduleSessionDigestRefresh(options) {
96
280
  if (inFlight.has(agentId)) {
97
281
  return;
98
282
  }
99
- const now = Date.now();
100
- if (now - (lastDigestAt.get(agentId) ?? 0) < DIGEST_DEBOUNCE_MS) {
101
- return;
102
- }
103
- lastDigestAt.set(agentId, now);
283
+ // The gate itself moved into generateAndApplySessionDigest: it needs the
284
+ // timeline to tell "nothing new happened" apart from "we ran recently", and
285
+ // only the former is a safe reason to skip a one-shot derivation.
104
286
  inFlight.add(agentId);
105
287
  queueMicrotask(() => {
106
288
  void generateAndApplySessionDigest(options)
@@ -109,6 +291,15 @@ export function scheduleSessionDigestRefresh(options) {
109
291
  })
110
292
  .finally(() => {
111
293
  inFlight.delete(agentId);
294
+ // Fires even on skip or failure: a judge that only ran on the happy path
295
+ // would go silent exactly when something went wrong, which is when the
296
+ // user most needs to hear about it.
297
+ try {
298
+ options.onSettled?.();
299
+ }
300
+ catch (error) {
301
+ options.logger.error({ err: error, agentId }, "Session digest onSettled hook threw");
302
+ }
112
303
  });
113
304
  });
114
305
  }
@@ -6,6 +6,7 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js
6
6
  import type { FirstAgentContext } from "../../messages.js";
7
7
  import type { TerminalManager } from "../../../terminal/terminal-manager.js";
8
8
  import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
9
+ import type { JudgeRelayGate } from "../judge-relay-gate.js";
9
10
  import type { ScheduleService } from "../../schedule/service.js";
10
11
  import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
11
12
  import type { GitHubService } from "../../../services/github-service.js";
@@ -48,6 +49,17 @@ export interface PaseoToolHostDependencies {
48
49
  resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null;
49
50
  enableVoiceTools?: boolean;
50
51
  voiceOnly?: boolean;
52
+ /**
53
+ * Restrict the surface to READ_ONLY_PASEO_TOOLS (JARVIS-V1-SPEC §6).
54
+ * Enforced by not registering the tool at all, never by prompt wording.
55
+ */
56
+ readOnly?: boolean;
57
+ /**
58
+ * Present only for the Jarvis judge. Enables `relay_to_agent`, the single
59
+ * mutating capability it may hold, gated at execution time on a human turn
60
+ * and on the target having been briefed. See judge-relay-gate.ts.
61
+ */
62
+ judgeRelayGate?: JudgeRelayGate;
51
63
  logger: Logger;
52
64
  }
53
65
  export declare function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog;
@@ -10,6 +10,7 @@ import { killTerminalsForWorkspace, } from "../../workspace-archive-service.js";
10
10
  import { WaitForAgentTracker } from "../wait-for-agent-tracker.js";
11
11
  import { createAgentCommand } from "../create-agent/create.js";
12
12
  import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
13
+ import { isReadOnlyPaseoTool } from "./read-only-surface.js";
13
14
  import { ScheduleRunSchema, ScheduleSummarySchema, StoredScheduleSchema, } from "@hyperdrive.bot/paseo-protocol/schedule/types";
14
15
  import { AgentModelSchema, AgentProviderEnum, AgentStatusEnum, ProviderModeSchema, ProviderSummarySchema, parseDurationString, resolveRequiredProviderModel, sanitizePermissionRequest, serializeSnapshotWithMetadata, toScheduleSummary, waitForAgentWithTimeout, } from "../mcp-shared.js";
15
16
  import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js";
@@ -249,6 +250,11 @@ export function createPaseoToolCatalog(options) {
249
250
  const registerTool = (name, config,
250
251
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries.
251
252
  handler) => {
253
+ // Read-only gate (JARVIS-V1-SPEC §6). Allowlist, so a mutating tool added
254
+ // later fails closed instead of silently joining a read-only surface.
255
+ if (options.readOnly && !isReadOnlyPaseoTool(name)) {
256
+ return;
257
+ }
252
258
  tools.set(name, {
253
259
  name,
254
260
  title: config.title,
@@ -743,6 +749,48 @@ export function createPaseoToolCatalog(options) {
743
749
  };
744
750
  });
745
751
  }
752
+ // The judge's one mutating capability. Registered through `tools.set` rather
753
+ // than `registerTool` on purpose: `registerTool` applies the read-only
754
+ // allowlist, and this tool must be available to a read-only agent precisely
755
+ // BECAUSE its own gate is stricter than the allowlist. Keeping it out of
756
+ // READ_ONLY_PASEO_TOOLS also keeps that list honestly "reads only".
757
+ if (options.judgeRelayGate) {
758
+ const gate = options.judgeRelayGate;
759
+ tools.set("relay_to_agent", {
760
+ name: "relay_to_agent",
761
+ title: "Relay the user's decision to an agent",
762
+ description: "Pass the user's instruction to an agent you were briefed about. Only usable when the user has just told you something in this conversation; it is refused while you are processing an event. You do not act yourself, the target agent acts with its own context and permissions.",
763
+ inputSchema: {
764
+ agentId: z.string().describe("The agent to relay to. Must be one you were briefed about."),
765
+ message: z
766
+ .string()
767
+ .trim()
768
+ .min(1)
769
+ .max(4000)
770
+ .describe("The user's instruction, in your words. Do not invent instructions."),
771
+ },
772
+ outputSchema: { ok: z.boolean(), agentId: z.string() },
773
+ handler: (async (args) => {
774
+ const decision = gate.evaluate(args.agentId);
775
+ if (!decision.allowed) {
776
+ throw new Error(decision.detail ?? "Relay refused");
777
+ }
778
+ const target = await ensureAgentLoaded(args.agentId, {
779
+ agentManager,
780
+ agentStorage,
781
+ logger: options.logger,
782
+ });
783
+ if (agentManager.hasInFlightRun(target.id)) {
784
+ throw new Error(`Agent ${args.agentId} is mid-run; try again once it settles.`);
785
+ }
786
+ await agentManager.runAgent(target.id, args.message);
787
+ return {
788
+ content: [],
789
+ structuredContent: ensureValidJson({ ok: true, agentId: args.agentId }),
790
+ };
791
+ }),
792
+ });
793
+ }
746
794
  if (options.voiceOnly) {
747
795
  return toCatalog();
748
796
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Read-only paseo tool surface (JARVIS-V1-SPEC §6, HARD REQUIREMENT).
3
+ *
4
+ * The Jarvis judge reads other sessions' contents in order to decide whether to
5
+ * interrupt the user. Those transcripts are arbitrary untrusted text by design:
6
+ * fetched web pages, repository files, tool output, error messages. Handing a
7
+ * reader of untrusted text a permission-granting tool creates a direct path from
8
+ * "text somebody else wrote" to "an approved action on the user's machine". No
9
+ * malice is required, only a transcript containing a sentence shaped like an
10
+ * instruction.
11
+ *
12
+ * A prompt saying "do not do this" is a suggestion to a model that is actively
13
+ * reading adversarial input. Absence of the tool is a wall. This module is the
14
+ * wall.
15
+ *
16
+ * ## Why an ALLOWLIST and not a denylist
17
+ *
18
+ * A denylist fails OPEN: the day somebody adds a new mutating tool, the judge
19
+ * silently gains it and nobody notices until it is used. An allowlist fails
20
+ * CLOSED: the new tool is simply unavailable until a human deliberately adds it
21
+ * here. The benign failure (a new read tool the judge cannot see yet) is worth
22
+ * far more than the dangerous one.
23
+ *
24
+ * ## Two registration paths, both gated
25
+ *
26
+ * Tools reach an agent through TWO places, and gating only one leaves a hole:
27
+ * 1. `paseo-tools.ts` `registerTool` (35 tools) -> filtered by this allowlist.
28
+ * 2. `mcp-server.ts`, which re-exposes the catalog and then registers 10 MORE
29
+ * of its own, including `set_session_digest`, `set_session_tags`,
30
+ * `open_url`, `workflow_start` and `workflow_cancel`. That path takes an
31
+ * early return in read-only mode, so none of its extras are exposed.
32
+ */
33
+ /**
34
+ * Every paseo tool a read-only agent may hold. Anything absent is denied.
35
+ *
36
+ * Deliberately excluded and why:
37
+ * - `respond_to_permission` grants permissions. The core injection target.
38
+ * - `send_agent_prompt` drives another agent. Action by proxy.
39
+ * - `update_agent`, `archive_agent`, `kill_agent`, `cancel_agent` mutate sessions.
40
+ * - `set_agent_mode` could lift the agent OUT of read-only. Privilege escalation.
41
+ * - `create_agent`, `create_worktree`, `archive_worktree` create or destroy.
42
+ * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
+ * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
+ * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `rename_workspace` mutates.
46
+ * - `speak` an outward side effect, and voice is out of v1 scope.
47
+ * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
48
+ * that blocks holds its run open indefinitely.
49
+ */
50
+ export declare const READ_ONLY_PASEO_TOOLS: ReadonlySet<string>;
51
+ export declare function isReadOnlyPaseoTool(name: string): boolean;
52
+ //# sourceMappingURL=read-only-surface.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Read-only paseo tool surface (JARVIS-V1-SPEC §6, HARD REQUIREMENT).
3
+ *
4
+ * The Jarvis judge reads other sessions' contents in order to decide whether to
5
+ * interrupt the user. Those transcripts are arbitrary untrusted text by design:
6
+ * fetched web pages, repository files, tool output, error messages. Handing a
7
+ * reader of untrusted text a permission-granting tool creates a direct path from
8
+ * "text somebody else wrote" to "an approved action on the user's machine". No
9
+ * malice is required, only a transcript containing a sentence shaped like an
10
+ * instruction.
11
+ *
12
+ * A prompt saying "do not do this" is a suggestion to a model that is actively
13
+ * reading adversarial input. Absence of the tool is a wall. This module is the
14
+ * wall.
15
+ *
16
+ * ## Why an ALLOWLIST and not a denylist
17
+ *
18
+ * A denylist fails OPEN: the day somebody adds a new mutating tool, the judge
19
+ * silently gains it and nobody notices until it is used. An allowlist fails
20
+ * CLOSED: the new tool is simply unavailable until a human deliberately adds it
21
+ * here. The benign failure (a new read tool the judge cannot see yet) is worth
22
+ * far more than the dangerous one.
23
+ *
24
+ * ## Two registration paths, both gated
25
+ *
26
+ * Tools reach an agent through TWO places, and gating only one leaves a hole:
27
+ * 1. `paseo-tools.ts` `registerTool` (35 tools) -> filtered by this allowlist.
28
+ * 2. `mcp-server.ts`, which re-exposes the catalog and then registers 10 MORE
29
+ * of its own, including `set_session_digest`, `set_session_tags`,
30
+ * `open_url`, `workflow_start` and `workflow_cancel`. That path takes an
31
+ * early return in read-only mode, so none of its extras are exposed.
32
+ */
33
+ /**
34
+ * Every paseo tool a read-only agent may hold. Anything absent is denied.
35
+ *
36
+ * Deliberately excluded and why:
37
+ * - `respond_to_permission` grants permissions. The core injection target.
38
+ * - `send_agent_prompt` drives another agent. Action by proxy.
39
+ * - `update_agent`, `archive_agent`, `kill_agent`, `cancel_agent` mutate sessions.
40
+ * - `set_agent_mode` could lift the agent OUT of read-only. Privilege escalation.
41
+ * - `create_agent`, `create_worktree`, `archive_worktree` create or destroy.
42
+ * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
+ * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
+ * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `rename_workspace` mutates.
46
+ * - `speak` an outward side effect, and voice is out of v1 scope.
47
+ * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
48
+ * that blocks holds its run open indefinitely.
49
+ */
50
+ export const READ_ONLY_PASEO_TOOLS = new Set([
51
+ "capture_terminal",
52
+ "get_agent_activity",
53
+ "get_agent_status",
54
+ "inspect_provider",
55
+ "inspect_schedule",
56
+ "list_agents",
57
+ "list_models",
58
+ "list_pending_permissions",
59
+ "list_providers",
60
+ "list_schedules",
61
+ "list_terminals",
62
+ "list_worktrees",
63
+ "schedule_logs",
64
+ ]);
65
+ export function isReadOnlyPaseoTool(name) {
66
+ return READ_ONLY_PASEO_TOOLS.has(name);
67
+ }
68
+ //# sourceMappingURL=read-only-surface.js.map
@@ -31,6 +31,11 @@ export interface PaseoToolRuntimeContext {
31
31
  callerAgentId?: string;
32
32
  enableVoiceTools?: boolean;
33
33
  voiceOnly?: boolean;
34
+ /**
35
+ * Restrict this session's surface to READ_ONLY_PASEO_TOOLS
36
+ * (JARVIS-V1-SPEC §6). Set by bootstrap for the Jarvis judge's agent id.
37
+ */
38
+ readOnly?: boolean;
34
39
  }
35
40
  export type PaseoToolCatalogFactory = (context: PaseoToolRuntimeContext) => PaseoToolCatalog | Promise<PaseoToolCatalog>;
36
41
  //# sourceMappingURL=types.d.ts.map
@@ -91,6 +91,10 @@ import { getCaptureStreamWs } from "./meetings/capture-stream-ws.js";
91
91
  import { createSpeechService } from "./speech/speech-runtime.js";
92
92
  import { AgentManager } from "./agent/agent-manager.js";
93
93
  import { scheduleSessionDigestRefresh } from "./agent/session-digest-generator.js";
94
+ import { CardMoveLog } from "./agent/card-move-log.js";
95
+ import { BlockerLog } from "./agent/blocker-log.js";
96
+ import { JudgeRelayGate } from "./agent/judge-relay-gate.js";
97
+ import { JarvisJudge } from "./agent/jarvis-judge.js";
94
98
  import { registerAgentRoutes } from "./agent/agent-routes.js";
95
99
  import { AgentStorage } from "./agent/agent-storage.js";
96
100
  import { attachAgentStoragePersistence } from "./persistence-hooks.js";
@@ -560,11 +564,33 @@ export async function createPaseoDaemon(config, rootLogger) {
560
564
  isDev: config.isDev === true,
561
565
  extraClients: config.agentClients,
562
566
  });
567
+ // Jarvis substrate (JARVIS-V1-SPEC §3.2). Append-only kanban move history.
568
+ // Prospective only: nothing before this ships is recoverable, which is why the
569
+ // spec sequences it ahead of every other piece.
570
+ const cardMoveLog = new CardMoveLog({ dir: config.paseoHome, logger });
571
+ // Blocker history. Same prospective-only property as the move log, and it
572
+ // answers a different question: not "how often was this deferred" but "who or
573
+ // what has kept blocking it". mergeDigest overwrites digest.blockers on every
574
+ // derivation, so without this the signal is destroyed on each idle.
575
+ const blockerLog = new BlockerLog({ dir: config.paseoHome, logger });
576
+ // Jarvis judge config (§5, §9.1). Env-driven in v1 to avoid protocol churn
577
+ // while the feature is still being shaped.
578
+ // Gate for the judge's one mutating capability. Enabled only when the judge
579
+ // itself is, so an unconfigured deployment cannot expose relay_to_agent.
580
+ const judgeRelayGate = new JudgeRelayGate({
581
+ enabled: Boolean(process.env.PASEO_JARVIS_AGENT_ID?.trim()),
582
+ });
583
+ const jarvisJudgeConfig = {
584
+ agentId: process.env.PASEO_JARVIS_AGENT_ID?.trim() || null,
585
+ shadow: process.env.PASEO_JARVIS_SHADOW === "1",
586
+ };
563
587
  const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
564
588
  const agentManager = new AgentManager({
565
589
  clients: initialAgentManagerState.clients,
566
590
  providerDefinitions: initialAgentManagerState.providerDefinitions,
567
591
  registry: agentStorage,
592
+ cardMoveLog,
593
+ blockerLog,
568
594
  appendSystemPrompt: config.appendSystemPrompt,
569
595
  onWorkspaceStateMayHaveChanged: ({ cwd }) => {
570
596
  workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
@@ -577,14 +603,49 @@ export async function createPaseoDaemon(config, rootLogger) {
577
603
  // and regenerate summary/accomplishments via the borrowed-provider rail (the
578
604
  // same one auto-title uses; the daemon holds no API key of its own). Debounced +
579
605
  // coalesced inside the scheduler; internal agents never broadcast attention.
606
+ // Jarvis judge (JARVIS-V1-SPEC §5.1). Constructed before the attention
607
+ // listener because the listener has to ORDER the two: see below.
608
+ const jarvisJudge = new JarvisJudge({
609
+ agentManager,
610
+ agentStorage,
611
+ cardMoveLog,
612
+ blockerLog,
613
+ relayGate: judgeRelayGate,
614
+ config: jarvisJudgeConfig,
615
+ logger,
616
+ });
617
+ if (jarvisJudge.enabled) {
618
+ logger.info({ judgeAgentId: jarvisJudgeConfig.agentId, shadow: jarvisJudgeConfig.shadow }, "Jarvis judge enabled");
619
+ }
580
620
  agentManager.addAgentAttentionListener((params) => {
621
+ // Never let a judge failure take down attention handling for everyone else.
622
+ const wakeJudge = () => {
623
+ void jarvisJudge.handleAttention({ agentId: params.agentId, reason: params.reason });
624
+ };
625
+ // `error` and `permission` have no derivation step, so nothing to wait for.
581
626
  if (params.reason !== "finished") {
627
+ if (jarvisJudge.enabled) {
628
+ wakeJudge();
629
+ }
582
630
  return;
583
631
  }
584
632
  const agent = agentManager.getAgent(params.agentId);
585
633
  if (!agent || agent.internal) {
586
634
  return;
587
635
  }
636
+ // ORDERING IS LOAD-BEARING, and getting it wrong is invisible.
637
+ //
638
+ // The digest refresh and the judge both hang off this one `finished` event.
639
+ // Fired in parallel, the judge wins the race every time and reads a digest
640
+ // that has not been written yet, so its briefing carries no summary, no
641
+ // blockers and no deadline. It then correctly concludes there is nothing
642
+ // worth saying and answers "quiet" forever. No error is raised anywhere: the
643
+ // feature looks healthy and delivers nothing. Verified live in the docker lab
644
+ // before this chaining existed.
645
+ //
646
+ // So the judge is woken from onSettled, which fires on success, skip AND
647
+ // failure. A judge that only ran on the happy path would go quiet exactly
648
+ // when something broke.
588
649
  scheduleSessionDigestRefresh({
589
650
  agentManager,
590
651
  agentId: params.agentId,
@@ -597,6 +658,7 @@ export async function createPaseoDaemon(config, rootLogger) {
597
658
  thinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? agent.config.thinkingOptionId ?? null,
598
659
  },
599
660
  logger,
661
+ onSettled: jarvisJudge.enabled ? wakeJudge : undefined,
600
662
  });
601
663
  });
602
664
  // Epic 1, Story 1.2 — first-class workflow entity. WorkflowManager orchestrates
@@ -849,6 +911,17 @@ export async function createPaseoDaemon(config, rootLogger) {
849
911
  callerAgentId: runtime.callerAgentId,
850
912
  enableVoiceTools: runtime.enableVoiceTools,
851
913
  voiceOnly: runtime.voiceOnly,
914
+ // JARVIS-V1-SPEC §6, HARD REQUIREMENT. The judge reads untrusted transcripts,
915
+ // so it must not hold a mutating tool. Keyed on identity rather than on a
916
+ // mode, because a mode is something the agent itself could change.
917
+ readOnly: runtime.readOnly ||
918
+ (jarvisJudgeConfig.agentId !== null && runtime.callerAgentId === jarvisJudgeConfig.agentId),
919
+ // Only the judge's own tool sessions get the relay tool, and even then its
920
+ // handler refuses unless a human turn is driving. Identity-keyed, like the
921
+ // read-only surface, so the agent cannot grant it to itself.
922
+ judgeRelayGate: jarvisJudgeConfig.agentId !== null && runtime.callerAgentId === jarvisJudgeConfig.agentId
923
+ ? judgeRelayGate
924
+ : undefined,
852
925
  resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
853
926
  resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
854
927
  logger,