@hyperdrive.bot/paseo-server 0.3.40 → 0.3.41

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 (30) hide show
  1. package/dist/server/server/agent/agent-manager.js +15 -0
  2. package/dist/server/server/agent/agent-projections.js +3 -0
  3. package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
  4. package/dist/server/server/agent/agent-storage.d.ts +2 -1
  5. package/dist/server/server/agent/agent-storage.js +4 -0
  6. package/dist/server/server/agent/mcp-shared.js +5 -2
  7. package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
  8. package/dist/server/server/agent/providers/claude/agent.js +74 -0
  9. package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
  10. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +6 -0
  11. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
  12. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
  13. package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
  14. package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
  15. package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
  16. package/dist/server/server/agent/tools/paseo-tools.js +213 -38
  17. package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
  18. package/dist/server/server/agent/tools/read-only-surface.js +1 -0
  19. package/dist/server/server/persistence-hooks.js +2 -0
  20. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js → index-cb251ddad56c08c3021036af3a43fc0f.js} +5 -5
  21. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
  22. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
  23. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.br → index-cb251ddad56c08c3021036af3a43fc0f.js.map.br} +0 -0
  24. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.gz → index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz} +0 -0
  25. package/dist/server/web-ui/index.html +1 -1
  26. package/dist/server/web-ui/index.html.br +0 -0
  27. package/dist/server/web-ui/index.html.gz +0 -0
  28. package/package.json +6 -6
  29. package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.br +0 -0
  30. package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.gz +0 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Per-run tool allowlist enforcement for the claude provider.
3
+ *
4
+ * WHY A HOOK AND NOT `--allowedTools`
5
+ * -----------------------------------
6
+ * `--allowedTools` (CLI) and `options.allowedTools` (SDK) are *permission allow
7
+ * rules*: they pre-approve a tool so it does not prompt. They do not restrict
8
+ * anything. Both of paseo's claude transports also run the child with
9
+ * `--dangerously-skip-permissions` / `allowDangerouslySkipPermissions`, under
10
+ * which every permission rule is moot, so passing an allowlist there would be
11
+ * decorative: accepted, parsed, and silently unenforcing.
12
+ *
13
+ * A `PreToolUse` hook is the one gate that still fires in that state. The Agent
14
+ * SDK says so explicitly (sdk.d.ts, PermissionDeniedHookInput): "PreToolUse hook
15
+ * denies bypass canUseTool". Verified against claude 2.1.239 with
16
+ * `--permission-mode acceptEdits --dangerously-skip-permissions`: a hook exiting
17
+ * 2 blocked a Bash call while an allowlisted Read call went through.
18
+ *
19
+ * FAIL CLOSED
20
+ * -----------
21
+ * When an allowlist is configured, anything this module cannot positively match
22
+ * is DENIED. A matcher that is too strict fails loudly (the model is told which
23
+ * rule set rejected it, and the run keeps going), while a matcher that is too
24
+ * lax fails silently and hands back a guarantee that is not real. Strictness is
25
+ * the safe direction, so unknown rule shapes and unknown specifier sources deny.
26
+ *
27
+ * Note this is *stricter* than `claude-pool launch --allowedTools`, where tools
28
+ * that never request permission (Read, Grep, TodoWrite, ...) were unaffected by
29
+ * the allowlist. Here the allowlist means exactly what it says: a tool absent
30
+ * from it cannot run at all.
31
+ */
32
+ /** Tools whose specifier (the `Tool(spec)` argument) we know how to read off the input. */
33
+ export const SPECIFIER_SOURCES = {
34
+ Bash: "command",
35
+ BashOutput: "bash_id",
36
+ Read: "file_path",
37
+ Edit: "file_path",
38
+ Write: "file_path",
39
+ NotebookEdit: "notebook_path",
40
+ WebFetch: "url",
41
+ Glob: "pattern",
42
+ Grep: "pattern",
43
+ };
44
+ /**
45
+ * Parse CSV-or-array allowlist entries into rules. Entries are the same strings
46
+ * Claude Code permission rules use: `Bash`, `Bash(git:*)`, `Read`,
47
+ * `mcp__server__tool`. Blank entries are dropped.
48
+ */
49
+ export function parseToolAllowlist(entries) {
50
+ const rules = [];
51
+ for (const raw of entries) {
52
+ for (const piece of raw.split(",")) {
53
+ const entry = piece.trim();
54
+ if (!entry)
55
+ continue;
56
+ const match = /^([^()\s]+)\((.*)\)$/.exec(entry);
57
+ if (match?.[1] !== undefined && match[2] !== undefined) {
58
+ rules.push({ tool: match[1], specifier: match[2].trim() });
59
+ }
60
+ else {
61
+ rules.push({ tool: entry });
62
+ }
63
+ }
64
+ }
65
+ return rules;
66
+ }
67
+ /** Escape a literal for use inside a RegExp, leaving `*` to be expanded by the caller. */
68
+ export function escapeLiteral(value) {
69
+ return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
70
+ }
71
+ /**
72
+ * Match a Claude-Code-style specifier pattern against a value.
73
+ *
74
+ * Supported shapes (anything else denies):
75
+ * - `*` any value
76
+ * - `prefix:*` value starts with `prefix` (Claude Code's command-prefix form)
77
+ * - `glob` `*` wildcards, everything else literal, anchored both ends
78
+ */
79
+ export function specifierMatches(pattern, value) {
80
+ if (pattern === "*")
81
+ return true;
82
+ const prefixForm = /^(.*):\*$/.exec(pattern);
83
+ if (prefixForm?.[1] !== undefined) {
84
+ const prefix = prefixForm[1].trim();
85
+ if (!prefix)
86
+ return false;
87
+ return value === prefix || value.startsWith(`${prefix} `);
88
+ }
89
+ const expanded = pattern.split("*").map(escapeLiteral).join(".*");
90
+ return new RegExp(`^${expanded}$`).test(value);
91
+ }
92
+ /** Read the specifier value a `Tool(spec)` rule compares against, or null when unknown. */
93
+ export function readSpecifierValue(toolName, input) {
94
+ const key = SPECIFIER_SOURCES[toolName];
95
+ if (!key)
96
+ return null;
97
+ if (typeof input !== "object" || input === null)
98
+ return null;
99
+ const value = input[key];
100
+ return typeof value === "string" ? value : null;
101
+ }
102
+ /**
103
+ * True when `toolName` (with `input`) is permitted by `rules`.
104
+ *
105
+ * An EMPTY rule list means "no allowlist configured" and allows everything; the
106
+ * caller is responsible for not installing the guard at all in that case.
107
+ */
108
+ export function isToolAllowed(toolName, input, rules) {
109
+ if (rules.length === 0)
110
+ return true;
111
+ for (const rule of rules) {
112
+ if (rule.tool !== toolName)
113
+ continue;
114
+ if (rule.specifier === undefined)
115
+ return true;
116
+ const value = readSpecifierValue(toolName, input);
117
+ // Unknown specifier source: we cannot verify the rule, so we do not honor it.
118
+ if (value === null)
119
+ continue;
120
+ if (specifierMatches(rule.specifier, value))
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ /** The message handed back to the model when a call is blocked. */
126
+ export function formatDenialMessage(toolName, rules) {
127
+ const listed = rules
128
+ .map((rule) => (rule.specifier === undefined ? rule.tool : `${rule.tool}(${rule.specifier})`))
129
+ .join(", ");
130
+ return (`Tool "${toolName}" is blocked by this run's tool allowlist and was not executed. ` +
131
+ `Allowed: ${listed}. Do not retry this tool; achieve the goal with an allowed tool or report that you cannot.`);
132
+ }
133
+ //# sourceMappingURL=tool-allowlist.js.map
@@ -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 ScheduleCadence } from "@hyperdrive.bot/paseo-protocol/schedule/types";
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";
@@ -62,5 +63,23 @@ export interface PaseoToolHostDependencies {
62
63
  judgeRelayGate?: JudgeRelayGate;
63
64
  logger: Logger;
64
65
  }
66
+ export interface ScheduleCreateCadenceArgs {
67
+ at?: string;
68
+ every?: string;
69
+ cron?: string;
70
+ timezone?: string;
71
+ }
72
+ export interface ResolvedScheduleCreateCadence {
73
+ cadence: ScheduleCadence;
74
+ /**
75
+ * `at` is a one-off. It lowers to an `every` cadence whose interval is the
76
+ * distance to the instant, pinned to a single run. An `every` schedule stores
77
+ * the computed `nextRunAt`, so a daemon that was down at that moment fires it
78
+ * late on the next tick, which is what a reminder wants. A pinned cron would
79
+ * instead skip silently to the next calendar match a year away.
80
+ */
81
+ oneOff: boolean;
82
+ }
83
+ export declare function resolveScheduleCreateCadence(input: ScheduleCreateCadenceArgs, now: Date): ResolvedScheduleCreateCadence;
65
84
  export declare function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog;
66
85
  //# sourceMappingURL=paseo-tools.d.ts.map
@@ -141,6 +141,51 @@ function resolveScheduleUpdateCadence(input) {
141
141
  }
142
142
  return undefined;
143
143
  }
144
+ // A duration ("90m", "2h30m", "45" seconds) rather than a timestamp. Checked
145
+ // first so `at` can accept both without Date parsing guessing at bare numbers.
146
+ const SCHEDULE_DURATION_ONLY_PATTERN = /^(?:\d+|(?:\d+[smhd])+)$/;
147
+ function resolveScheduleAtInstant(at, now) {
148
+ if (SCHEDULE_DURATION_ONLY_PATTERN.test(at)) {
149
+ return new Date(now.getTime() + parseDurationString(at));
150
+ }
151
+ const parsed = new Date(at);
152
+ if (Number.isNaN(parsed.getTime())) {
153
+ throw new Error(`Invalid at value: ${at}. Use an ISO 8601 instant (2026-08-21T21:30:00-03:00) or a duration from now (90m).`);
154
+ }
155
+ return parsed;
156
+ }
157
+ export function resolveScheduleCreateCadence(input, now) {
158
+ const at = normalizeScheduleCadenceArg(input.at);
159
+ const every = normalizeScheduleCadenceArg(input.every);
160
+ const cron = normalizeScheduleCadenceArg(input.cron);
161
+ const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
162
+ const provided = [at, every, cron].filter((value) => value !== undefined);
163
+ if (provided.length !== 1) {
164
+ throw new Error("Specify exactly one of at, every, or cron");
165
+ }
166
+ if (timeZone !== undefined && cron === undefined) {
167
+ throw new Error("timezone can only be used with cron");
168
+ }
169
+ if (at !== undefined) {
170
+ const instant = resolveScheduleAtInstant(at, now);
171
+ const everyMs = instant.getTime() - now.getTime();
172
+ if (everyMs <= 0) {
173
+ throw new Error(`at must be in the future: ${instant.toISOString()} is not after ${now.toISOString()}`);
174
+ }
175
+ return { cadence: { type: "every", everyMs }, oneOff: true };
176
+ }
177
+ if (every !== undefined) {
178
+ return { cadence: { type: "every", everyMs: parseDurationString(every) }, oneOff: false };
179
+ }
180
+ return {
181
+ cadence: {
182
+ type: "cron",
183
+ expression: cron,
184
+ ...(timeZone !== undefined ? { timezone: timeZone } : {}),
185
+ },
186
+ oneOff: false,
187
+ };
188
+ }
144
189
  function resolveScheduleUpdateExpiresAt(input) {
145
190
  if (input.expiresIn !== undefined && input.clearExpires) {
146
191
  throw new Error("Specify at most one of expiresIn or clearExpires");
@@ -277,18 +322,6 @@ export function createPaseoToolCatalog(options) {
277
322
  return tool.handler(await parseToolInput(tool, input), context);
278
323
  },
279
324
  });
280
- const buildCronScheduleCadence = (input) => {
281
- const expression = input.cron?.trim() ?? "";
282
- if (!expression) {
283
- throw new Error("cron is required");
284
- }
285
- const timezone = normalizeScheduleTimeZoneArg(input.timezone);
286
- return {
287
- type: "cron",
288
- expression,
289
- ...(timezone !== undefined ? { timezone } : {}),
290
- };
291
- };
292
325
  const buildScheduleExpiry = (expiresIn) => {
293
326
  return expiresIn === undefined
294
327
  ? undefined
@@ -427,6 +460,59 @@ export function createPaseoToolCatalog(options) {
427
460
  },
428
461
  };
429
462
  };
463
+ // A schedule bound to an agent that is gone can never fire: the startup sweep
464
+ // completes it on sight. Fail loudly at create time instead of handing back a
465
+ // schedule that reads active and is already dead.
466
+ const assertScheduleTargetAgentExists = async (agentId) => {
467
+ if (agentManager.getAgent(agentId)) {
468
+ return;
469
+ }
470
+ const record = await agentStorage.get(agentId);
471
+ if (!record || record.internal) {
472
+ throw new Error(`Agent ${agentId} not found`);
473
+ }
474
+ if (record.archivedAt) {
475
+ throw new Error(`Agent ${agentId} is archived and cannot be a schedule target`);
476
+ }
477
+ };
478
+ const resolveScheduleCreateTarget = async (params) => {
479
+ const target = params.target?.trim();
480
+ // Default stays new-agent so callers written against the narrower schema
481
+ // keep the behaviour they had before target existed.
482
+ if (!target || target === "new-agent") {
483
+ return resolveNewAgentScheduleTarget({
484
+ ...(params.provider !== undefined ? { provider: params.provider } : {}),
485
+ ...(params.cwd !== undefined ? { cwd: params.cwd } : {}),
486
+ });
487
+ }
488
+ if (params.provider !== undefined || params.cwd !== undefined) {
489
+ throw new Error("provider and cwd can only be used with target new-agent");
490
+ }
491
+ if (target === "self") {
492
+ if (!callerAgentId) {
493
+ throw new Error('target "self" requires an agent-scoped session');
494
+ }
495
+ resolveCallerAgent();
496
+ return { type: "agent", agentId: callerAgentId };
497
+ }
498
+ await assertScheduleTargetAgentExists(target);
499
+ return { type: "agent", agentId: target };
500
+ };
501
+ // Recurring creates go through createOrReplace so an agent re-registering the
502
+ // same standing schedule refreshes it instead of minting a twin. A one-off must
503
+ // NOT: createOrReplace carries the existing schedule's `runs` forward, and
504
+ // shouldCompleteSchedule counts them against the incoming maxRuns before tick
505
+ // looks at nextRunAt, so replacing a schedule that has already run with
506
+ // maxRuns 1 completes it on the next tick without ever firing. There is also
507
+ // nothing to make idempotent about a one-off: two calls mean two reminders.
508
+ // Covered by schedule/service.test.ts "replacing a schedule that has run with
509
+ // maxRuns 1 buries it without firing".
510
+ const persistNewSchedule = async (input, oneOff) => {
511
+ if (!scheduleService) {
512
+ throw new Error("Schedule service is not configured");
513
+ }
514
+ return oneOff ? scheduleService.create(input) : scheduleService.createOrReplace(input);
515
+ };
430
516
  const ProviderModelInputSchema = AgentProviderEnum.trim()
431
517
  .refine((value) => value.includes("/"), {
432
518
  message: "provider must be provider/model, for example codex/gpt-5.4",
@@ -1609,39 +1695,80 @@ export function createPaseoToolCatalog(options) {
1609
1695
  });
1610
1696
  registerTool("create_schedule", {
1611
1697
  title: "Create schedule",
1612
- description: "Create a recurring schedule that starts a new agent on a cron cadence.",
1698
+ description: "Schedule a prompt for later: once at a specific time (at), on a repeating interval (every), or on a cron cadence (cron). The prompt can start a fresh agent, come back to you (target self), or go to another agent by id. Prefer this over an in-session loop whenever the work must survive this session ending, since schedules are owned by the daemon.",
1613
1699
  inputSchema: {
1614
1700
  prompt: z.string().trim().min(1, "prompt is required"),
1615
- cron: z.string().trim().min(1, "cron is required"),
1701
+ at: z
1702
+ .string()
1703
+ .trim()
1704
+ .min(1)
1705
+ .optional()
1706
+ .describe('One-off delivery time. An ISO 8601 instant ("2026-08-21T21:30:00-03:00") or a duration from now ("90m", "2h30m"). Runs exactly once. Mutually exclusive with every and cron.'),
1707
+ every: z
1708
+ .string()
1709
+ .trim()
1710
+ .min(1)
1711
+ .optional()
1712
+ .describe('Repeating interval, for example "5m", "1h", "2h30m", "1d". Mutually exclusive with at and cron.'),
1713
+ cron: z
1714
+ .string()
1715
+ .trim()
1716
+ .min(1)
1717
+ .optional()
1718
+ .describe('Repeating cron expression, for example "0 9 * * 1-5". Mutually exclusive with at and every.'),
1616
1719
  timezone: z
1617
1720
  .string()
1618
1721
  .trim()
1619
1722
  .min(1)
1620
1723
  .optional()
1621
- .describe("IANA time zone for the cron cadence. For example: America/New_York."),
1622
- name: z.string().optional(),
1623
- provider: AgentProviderEnum.optional().describe("Provider, or provider/model (for example: codex or codex/gpt-5.4)."),
1624
- cwd: z.string().optional(),
1724
+ .describe("IANA time zone for the cron cadence. Requires cron. For example: America/New_York."),
1725
+ name: z
1726
+ .string()
1727
+ .optional()
1728
+ .describe("Reusing a name with the same target refreshes that schedule in place instead of creating a second one."),
1729
+ target: z
1730
+ .string()
1731
+ .trim()
1732
+ .min(1)
1733
+ .optional()
1734
+ .describe('Who receives the prompt: "new-agent" (default) starts a fresh agent each run, "self" delivers to you, or an agent id delivers to that agent.'),
1735
+ provider: AgentProviderEnum.optional().describe("Provider, or provider/model (for example: codex or codex/gpt-5.4). Only valid with target new-agent."),
1736
+ cwd: z.string().optional().describe("Working directory. Only valid with target new-agent."),
1625
1737
  maxRuns: z.number().int().positive().optional(),
1626
1738
  expiresIn: z.string().optional(),
1739
+ runNow: z
1740
+ .boolean()
1741
+ .optional()
1742
+ .describe("Also fire once immediately on create. Defaults to true for every, false for cron. Not allowed with at."),
1627
1743
  },
1628
1744
  outputSchema: ScheduleSummarySchema.shape,
1629
- }, async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => {
1745
+ }, async ({ prompt, at, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn, runNow, }) => {
1630
1746
  if (!scheduleService) {
1631
1747
  throw new Error("Schedule service is not configured");
1632
1748
  }
1749
+ const { cadence, oneOff } = resolveScheduleCreateCadence({ at, every, cron, timezone }, new Date());
1750
+ if (oneOff && maxRuns !== undefined && maxRuns !== 1) {
1751
+ throw new Error("maxRuns cannot be combined with at: an at schedule runs exactly once");
1752
+ }
1753
+ if (oneOff && runNow === true) {
1754
+ throw new Error("runNow cannot be combined with at: an at schedule runs only at its time");
1755
+ }
1756
+ const resolvedTarget = await resolveScheduleCreateTarget({ target, provider, cwd });
1633
1757
  const expiresAt = buildScheduleExpiry(expiresIn);
1634
- const schedule = await scheduleService.createOrReplace({
1758
+ const scheduleInput = {
1635
1759
  prompt: prompt.trim(),
1636
- cadence: buildCronScheduleCadence({
1637
- cron,
1638
- ...(timezone !== undefined ? { timezone } : {}),
1639
- }),
1640
- target: resolveNewAgentScheduleTarget({ provider, cwd }),
1760
+ cadence,
1761
+ target: resolvedTarget,
1641
1762
  ...(name?.trim() ? { name: name.trim() } : {}),
1642
- ...(maxRuns === undefined ? {} : { maxRuns }),
1763
+ ...(oneOff
1764
+ ? { maxRuns: 1, runOnCreate: false }
1765
+ : {
1766
+ ...(maxRuns === undefined ? {} : { maxRuns }),
1767
+ ...(runNow === undefined ? {} : { runOnCreate: runNow }),
1768
+ }),
1643
1769
  ...(expiresAt === undefined ? {} : { expiresAt }),
1644
- });
1770
+ };
1771
+ const schedule = await persistNewSchedule(scheduleInput, oneOff);
1645
1772
  return {
1646
1773
  content: [],
1647
1774
  structuredContent: ensureValidJson(toScheduleSummary(schedule)),
@@ -1649,22 +1776,43 @@ export function createPaseoToolCatalog(options) {
1649
1776
  });
1650
1777
  registerTool("create_heartbeat", {
1651
1778
  title: "Create heartbeat",
1652
- description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
1779
+ description: "Schedule a prompt back to yourself: once at a specific time (at), on a repeating interval (every), or on a cron cadence (cron). Equivalent to create_schedule with target self.",
1653
1780
  inputSchema: {
1654
1781
  prompt: z.string().trim().min(1, "prompt is required"),
1655
- cron: z.string().trim().min(1, "cron is required"),
1782
+ at: z
1783
+ .string()
1784
+ .trim()
1785
+ .min(1)
1786
+ .optional()
1787
+ .describe('One-off delivery time. An ISO 8601 instant ("2026-08-21T21:30:00-03:00") or a duration from now ("90m"). Runs exactly once. Mutually exclusive with every and cron.'),
1788
+ every: z
1789
+ .string()
1790
+ .trim()
1791
+ .min(1)
1792
+ .optional()
1793
+ .describe('Repeating interval, for example "5m", "1h", "2h30m". Mutually exclusive with at and cron.'),
1794
+ cron: z
1795
+ .string()
1796
+ .trim()
1797
+ .min(1)
1798
+ .optional()
1799
+ .describe('Repeating cron expression, for example "0 9 * * 1-5". Mutually exclusive with at and every.'),
1656
1800
  timezone: z
1657
1801
  .string()
1658
1802
  .trim()
1659
1803
  .min(1)
1660
1804
  .optional()
1661
- .describe("IANA time zone for the cron cadence. For example: America/New_York."),
1805
+ .describe("IANA time zone for the cron cadence. Requires cron. For example: America/New_York."),
1662
1806
  name: z.string().optional(),
1663
1807
  maxRuns: z.number().int().positive().optional(),
1664
1808
  expiresIn: z.string().optional(),
1809
+ runNow: z
1810
+ .boolean()
1811
+ .optional()
1812
+ .describe("Also fire once immediately on create. Defaults to true for every, false for cron. Not allowed with at."),
1665
1813
  },
1666
1814
  outputSchema: ScheduleSummarySchema.shape,
1667
- }, async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
1815
+ }, async ({ prompt, at, every, cron, timezone, name, maxRuns, expiresIn, runNow }) => {
1668
1816
  if (!scheduleService) {
1669
1817
  throw new Error("Schedule service is not configured");
1670
1818
  }
@@ -1672,23 +1820,50 @@ export function createPaseoToolCatalog(options) {
1672
1820
  throw new Error("create_heartbeat requires an agent-scoped session");
1673
1821
  }
1674
1822
  resolveCallerAgent();
1823
+ const { cadence, oneOff } = resolveScheduleCreateCadence({ at, every, cron, timezone }, new Date());
1824
+ if (oneOff && maxRuns !== undefined && maxRuns !== 1) {
1825
+ throw new Error("maxRuns cannot be combined with at: an at schedule runs exactly once");
1826
+ }
1827
+ if (oneOff && runNow === true) {
1828
+ throw new Error("runNow cannot be combined with at: an at schedule runs only at its time");
1829
+ }
1675
1830
  const expiresAt = buildScheduleExpiry(expiresIn);
1676
- const schedule = await scheduleService.createOrReplace({
1831
+ const scheduleInput = {
1677
1832
  prompt: prompt.trim(),
1678
- cadence: buildCronScheduleCadence({
1679
- cron,
1680
- ...(timezone !== undefined ? { timezone } : {}),
1681
- }),
1833
+ cadence,
1682
1834
  target: { type: "agent", agentId: callerAgentId },
1683
1835
  ...(name?.trim() ? { name: name.trim() } : {}),
1684
- ...(maxRuns === undefined ? {} : { maxRuns }),
1836
+ ...(oneOff
1837
+ ? { maxRuns: 1, runOnCreate: false }
1838
+ : {
1839
+ ...(maxRuns === undefined ? {} : { maxRuns }),
1840
+ ...(runNow === undefined ? {} : { runOnCreate: runNow }),
1841
+ }),
1685
1842
  ...(expiresAt === undefined ? {} : { expiresAt }),
1686
- });
1843
+ };
1844
+ const schedule = await persistNewSchedule(scheduleInput, oneOff);
1687
1845
  return {
1688
1846
  content: [],
1689
1847
  structuredContent: ensureValidJson(toScheduleSummary(schedule)),
1690
1848
  };
1691
1849
  });
1850
+ registerTool("run_schedule_once", {
1851
+ title: "Run schedule once",
1852
+ description: "Fire a schedule immediately, out of band. The cadence and the next scheduled run are left untouched, but the run is recorded and counts toward maxRuns.",
1853
+ inputSchema: {
1854
+ id: z.string(),
1855
+ },
1856
+ outputSchema: StoredScheduleSchema.shape,
1857
+ }, async ({ id }) => {
1858
+ if (!scheduleService) {
1859
+ throw new Error("Schedule service is not configured");
1860
+ }
1861
+ const schedule = await scheduleService.runOnce(id);
1862
+ return {
1863
+ content: [],
1864
+ structuredContent: ensureValidJson(schedule),
1865
+ };
1866
+ });
1692
1867
  registerTool("list_schedules", {
1693
1868
  title: "List schedules",
1694
1869
  description: "List all schedules managed by the daemon.",
@@ -42,6 +42,7 @@
42
42
  * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
43
  * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
44
  * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
45
46
  * - `rename_workspace` mutates.
46
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
47
48
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
@@ -42,6 +42,7 @@
42
42
  * - `create_terminal`, `kill_terminal`, `send_terminal_keys` arbitrary command execution.
43
43
  * - `create_schedule`, `update_schedule`, `delete_schedule`,
44
44
  * `pause_schedule`, `resume_schedule`, `create_heartbeat` persistent side effects.
45
+ * - `run_schedule_once` fires a schedule now, which starts or prompts an agent.
45
46
  * - `rename_workspace` mutates.
46
47
  * - `speak` an outward side effect, and voice is out of v1 scope.
47
48
  * - `wait_for_agent` not mutating, but it BLOCKS. An event-woken judge
@@ -41,6 +41,7 @@ export function buildConfigOverrides(record) {
41
41
  extra: record.config?.extra ?? undefined,
42
42
  systemPrompt: record.config?.systemPrompt ?? undefined,
43
43
  mcpServers: record.config?.mcpServers ?? undefined,
44
+ allowedTools: record.config?.allowedTools ?? undefined,
44
45
  });
45
46
  }
46
47
  export function buildSessionConfig(record, options) {
@@ -58,6 +59,7 @@ export function buildSessionConfig(record, options) {
58
59
  extra: overrides.extra,
59
60
  systemPrompt: overrides.systemPrompt,
60
61
  mcpServers: overrides.mcpServers,
62
+ allowedTools: overrides.allowedTools,
61
63
  });
62
64
  }
63
65
  export function isStoredAgentProviderAvailable(record, validProviders) {