claude-tempo 0.11.1 → 0.12.0

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.
package/CLAUDE.md CHANGED
@@ -9,7 +9,8 @@ claude-tempo is an MCP server that enables multiple Claude Code sessions to coor
9
9
  - **Runtime**: Node.js 18+ with TypeScript
10
10
  - **MCP**: `@modelcontextprotocol/sdk` (stdio transport)
11
11
  - **Temporal**: `@temporalio/client`, `@temporalio/worker`, `@temporalio/workflow`, `@temporalio/activity`
12
- - **No other dependencies** — no database, no custom broker
12
+ - **croner** — cron expression parsing and next-fire computation (used by `schedule` tool)
13
+ - **yaml**, **zod** — lineup parsing and schema validation
13
14
 
14
15
  ## Project Structure
15
16
 
@@ -111,9 +112,9 @@ npm test
111
112
  - **Broadcast**: Fan-out variant of `cue` — sends a message to all active players in the ensemble in a single call. Optionally filtered by player type. Skips the sender, pending sessions, and (by default) stale sessions.
112
113
  - **Recall**: Queries a session's own message history from the Temporal workflow. Shows received messages by default; pass `includeSent: true` to also see sent messages. Supports `limit`, `since`, and `from` filters.
113
114
  - **Per-host task queues**: Each host runs a `claude-tempo-{hostname}` activity worker for local-only operations (e.g., `spawnProcess`). This enables cross-machine recruiting — the `recruit` tool accepts an optional `host` parameter to route the spawn to a remote machine's task queue.
114
- - **Player types**: Reusable agent definitions in Claude Code's standard subagent format (`.md` files with YAML frontmatter). Ensemble lineups can reference types by name via a `type` field on players. Three-tier lookup: project `.claude/agents/` → user `~/.claude/agents/` → shipped `examples/agents/`. Players know their type via workflow metadata and the `who_am_i` tool.
115
+ - **Player types**: Reusable agent definitions in Claude Code's standard subagent format (`.md` files with YAML frontmatter). Ensemble lineups can reference types by name via a `type` field on players. Three-tier lookup: project `.claude/agents/` → user `~/.claude/agents/` → shipped `examples/agents/`. Players know their type via workflow metadata and the `who_am_i` tool. Agent type frontmatter may include an `allowedTools` array to restrict which MCP/CLI tools the spawned session can use (e.g., `allowedTools: [Read, Glob, Grep]`). When present, the type's `allowedTools` overrides any lineup-level setting and is passed to the Claude Code session via `--allowedTools`.
115
116
  - **Agent type discovery**: The `agent_types` MCP tool and `claude-tempo agent-types` CLI command let conductors discover available player types. Shipped examples (tempo-conductor, tempo-composer, tempo-soloist, tempo-tuner, tempo-critic, tempo-roadie, tempo-improv, tempo-liner) work out of the box. Ensemble lineups: tempo-big-band (full lifecycle), tempo-dev-team (feature work), tempo-review-squad (parallel review), tempo-jam-session (exploration).
116
- - **Schedule**: A one-shot or recurring message delivery configured via the `schedule` tool. Backed by a durable `claudeSchedulerWorkflow` — survives restarts. Supports delay, cron-style intervals, and time-bounded delivery. Managed via `schedule`, `unschedule`, and `schedules` tools.
117
+ - **Schedule**: A one-shot or recurring message delivery configured via the `schedule` tool. Backed by a durable `claudeSchedulerWorkflow` — survives restarts. Supports delay (`delay`), fixed time (`at`), recurring interval (`every`), and cron expressions (`cron`) with optional IANA timezone (`timezone`). Cron schedules use `croner` for expression parsing and next-fire computation. Managed via `schedule`, `unschedule`, and `schedules` tools.
117
118
  - **Lineup**: A YAML file defining an ensemble configuration — which players to recruit, their types, working directories, and optional startup messages. Load via `load_lineup` to bootstrap a full ensemble in one step; save via `save_lineup` to snapshot a running ensemble's state for later reuse.
118
119
  - **Wire protocol**: All Temporal signal, query, update, and workflow names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md). These names are stable as of v0.10 — renaming or removing any is a breaking change requiring a major version bump.
119
120
 
package/README.md CHANGED
@@ -150,11 +150,20 @@ Tell your session things like:
150
150
  - *"Schedule a check every hour called 'deploy-watch' — cue ops to check deployment status"*
151
151
  - *"Remind me in 30 minutes to review PR #42"*
152
152
  - *"Every 5 minutes for the next hour, ping frontend to check their progress"*
153
- - *"Set up a daily standup reminder at 9am UTC for the conductor"*
153
+ - *"Set up a daily standup at 9am New York time, weekdays only"*
154
154
  - *"Cancel the deploy-watch schedule"*
155
155
  - *"Show me all active schedules"*
156
156
 
157
- Schedules support one-shot delays, fixed times, and recurring intervals with optional bounds (max count or end time).
157
+ Schedules support four timing modes all accept optional bounds (`count` max fires, `until` end time):
158
+
159
+ | Mode | Parameter | Example |
160
+ |------|-----------|---------|
161
+ | One-shot delay | `delay` | `"10m"`, `"2h"`, `"1d"` |
162
+ | Fixed time | `at` | `"2026-04-03T20:00:00Z"` |
163
+ | Recurring interval | `every` | `"5m"`, `"1h"` |
164
+ | Cron expression | `cron` + optional `timezone` | `"0 9 * * 1-5"` (weekdays 9am) |
165
+
166
+ The `timezone` parameter accepts any IANA timezone (e.g. `"America/New_York"`, `"Europe/London"`). Defaults to UTC when omitted.
158
167
 
159
168
  ### How it works
160
169
 
@@ -241,7 +250,7 @@ players:
241
250
 
242
251
  ## Player Types
243
252
 
244
- Player types are reusable agent definitions in Claude Code's standard subagent format — `.md` files with YAML frontmatter specifying name, description, and optional model. They let you define specialized roles once and reuse them across lineups.
253
+ Player types are reusable agent definitions in Claude Code's standard subagent format — `.md` files with YAML frontmatter specifying name, description, optional model, and optional tool restrictions. They let you define specialized roles once and reuse them across lineups.
245
254
 
246
255
  ### How player types work
247
256
 
@@ -257,6 +266,23 @@ players:
257
266
 
258
267
  When a player is recruited with a type, the agent definition is resolved and passed to the session. Players know their type via the `who_am_i` tool.
259
268
 
269
+ ### Tool restrictions (`allowedTools`)
270
+
271
+ Agent type frontmatter may include an `allowedTools` array to restrict which tools the spawned session can use. When present, it is passed to the Claude Code session via `--allowedTools` and overrides any lineup-level setting.
272
+
273
+ ```yaml
274
+ ---
275
+ name: tempo-reviewer
276
+ description: Read-only code reviewer
277
+ allowedTools:
278
+ - Read
279
+ - Glob
280
+ - Grep
281
+ ---
282
+ ```
283
+
284
+ This is useful for security-sensitive roles (read-only reviewers, auditors) or to prevent specific players from making changes outside their scope. Sessions launched without a type, or with a type that omits `allowedTools`, receive no tool restrictions.
285
+
260
286
  ### Three-tier lookup
261
287
 
262
288
  Player types are resolved in order (first match wins):
@@ -30,6 +30,7 @@ export interface StartRecruitedSessionInput {
30
30
  taskQueue: string;
31
31
  agentDefinition?: string;
32
32
  agentDefinitionDescription?: string;
33
+ allowedTools?: string[];
33
34
  }
34
35
  export interface SpawnProcessInput {
35
36
  targetName: string;
@@ -45,6 +46,8 @@ export interface SpawnProcessInput {
45
46
  nativeResolvable?: boolean;
46
47
  /** When true, use --resume instead of -n (reconnect to existing session). */
47
48
  resume?: boolean;
49
+ /** Tool restrictions from the agent definition frontmatter. */
50
+ allowedTools?: string[];
48
51
  }
49
52
  export interface PerformEncoreInput {
50
53
  ensemble: string;
@@ -60,6 +63,7 @@ export interface EncoreResult {
60
63
  agentDefinition?: string;
61
64
  agentDefinitionPath?: string;
62
65
  nativeResolvable?: boolean;
66
+ allowedTools?: string[];
63
67
  temporalAddress: string;
64
68
  temporalNamespace: string;
65
69
  }
@@ -145,11 +145,14 @@ function createOutboxActivities(client, config) {
145
145
  }
146
146
  },
147
147
  async spawnProcess(input) {
148
- const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume } = input;
148
+ const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, allowedTools } = input;
149
149
  // Read secrets from the worker's config closure — never from workflow state
150
150
  const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
151
151
  try {
152
152
  if (agent === 'copilot') {
153
+ if (allowedTools && allowedTools.length > 0) {
154
+ log(`Warning: allowedTools [${allowedTools.join(', ')}] specified for copilot agent "${targetName}" — copilot bridge does not support --allowedTools, skipping`);
155
+ }
153
156
  const { pid } = (0, spawn_1.spawnCopilotBridge)({
154
157
  name: targetName,
155
158
  ensemble,
@@ -177,11 +180,16 @@ function createOutboxActivities(client, config) {
177
180
  }
178
181
  // Use --resume for encore (reconnect to existing session) or -n for new sessions
179
182
  const nameArgs = resume ? ['--resume', targetName] : ['-n', targetName];
183
+ // Build --allowedTools flag from agent definition frontmatter
184
+ const allowedToolsFlags = allowedTools && allowedTools.length > 0
185
+ ? ['--allowedTools', ...allowedTools]
186
+ : [];
180
187
  const spawnArgs = [
181
188
  '--dangerously-skip-permissions',
182
189
  '--dangerously-load-development-channels', 'server:claude-tempo',
183
190
  ...nameArgs,
184
191
  ...agentFlags,
192
+ ...allowedToolsFlags,
185
193
  ];
186
194
  const envVars = {
187
195
  [config_2.ENV.ENSEMBLE]: ensemble,
@@ -249,12 +257,14 @@ function createOutboxActivities(client, config) {
249
257
  const playerType = metadata.playerType;
250
258
  let agentDefinitionPath;
251
259
  let nativeResolvable;
260
+ let allowedTools;
252
261
  if (playerType) {
253
262
  try {
254
263
  const info = (0, agent_types_1.resolveAgentType)(playerType);
255
264
  if (info) {
256
265
  agentDefinitionPath = info.path;
257
266
  nativeResolvable = info.nativeResolvable;
267
+ allowedTools = info.allowedTools;
258
268
  }
259
269
  }
260
270
  catch {
@@ -269,6 +279,7 @@ function createOutboxActivities(client, config) {
269
279
  agentDefinition: playerType,
270
280
  agentDefinitionPath,
271
281
  nativeResolvable,
282
+ allowedTools,
272
283
  temporalAddress: config.temporalAddress,
273
284
  temporalNamespace: config.temporalNamespace,
274
285
  };
@@ -10,9 +10,14 @@ export interface FireScheduleResult {
10
10
  success: boolean;
11
11
  error?: string;
12
12
  }
13
+ export interface ComputeNextCronInput {
14
+ cronExpression: string;
15
+ timezone?: string;
16
+ }
13
17
  /** Activity interface — used by proxyActivities in the scheduler workflow. */
14
18
  export interface ScheduleActivities {
15
19
  fireSchedule(input: FireScheduleInput): Promise<FireScheduleResult>;
20
+ computeNextCronFire(input: ComputeNextCronInput): Promise<string | null>;
16
21
  }
17
22
  /**
18
23
  * Create the schedule-fire activity bound to a Temporal client.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createScheduleActivities = createScheduleActivities;
4
+ const croner_1 = require("croner");
4
5
  const config_1 = require("../config");
5
6
  const resolve_1 = require("./resolve");
6
7
  /**
@@ -9,6 +10,11 @@ const resolve_1 = require("./resolve");
9
10
  */
10
11
  function createScheduleActivities(client) {
11
12
  return {
13
+ async computeNextCronFire(input) {
14
+ const job = new croner_1.Cron(input.cronExpression, { timezone: input.timezone || 'UTC' });
15
+ const next = job.nextRun();
16
+ return next ? next.toISOString() : null;
17
+ },
12
18
  async fireSchedule(input) {
13
19
  const { ensemble, scheduleName, message, target, createdBy } = input;
14
20
  try {
@@ -77,6 +77,7 @@ function listAgentTypes(cwd) {
77
77
  source,
78
78
  path: filePath,
79
79
  nativeResolvable,
80
+ ...(Array.isArray(fm.allowedTools) ? { allowedTools: fm.allowedTools.map(String) } : {}),
80
81
  });
81
82
  }
82
83
  }
@@ -98,6 +99,7 @@ function resolveAgentType(name, cwd) {
98
99
  source,
99
100
  path: filePath,
100
101
  nativeResolvable,
102
+ ...(Array.isArray(fm.allowedTools) ? { allowedTools: fm.allowedTools.map(String) } : {}),
101
103
  };
102
104
  }
103
105
  return null;
@@ -120,6 +122,11 @@ function loadAndResolveLineup(filePath, cwd) {
120
122
  }
121
123
  player._agentDefinition = info.name;
122
124
  player._agentDefinitionPath = info.path;
125
+ // Type's allowedTools is the security authority — overrides lineup-level setting
126
+ // Empty array means "not specified" (no restriction), so don't override
127
+ if (info.allowedTools && info.allowedTools.length > 0) {
128
+ player.allowedTools = info.allowedTools;
129
+ }
123
130
  }
124
131
  return lineup;
125
132
  }
@@ -45,8 +45,8 @@ function loadLineup(filePath) {
45
45
  if (typeof s.target !== 'string' || !s.target) {
46
46
  throw new Error(`Invalid lineup: schedules[${i}].target is required`);
47
47
  }
48
- if (!s.at && !s.delay && !s.every) {
49
- throw new Error(`Invalid lineup: schedules[${i}] must have at least one of: at, delay, every`);
48
+ if (!s.at && !s.delay && !s.every && !s.cron) {
49
+ throw new Error(`Invalid lineup: schedules[${i}] must have at least one of: at, delay, every, cron`);
50
50
  }
51
51
  }
52
52
  }
@@ -60,6 +60,7 @@ function loadLineup(filePath) {
60
60
  ...(p.workDir != null && { workDir: p.workDir }),
61
61
  ...(p.agent != null && { agent: p.agent }),
62
62
  ...(p.instructions != null && { instructions: p.instructions }),
63
+ ...(Array.isArray(p.allowedTools) && { allowedTools: p.allowedTools.map(String) }),
63
64
  })),
64
65
  schedules: doc.schedules,
65
66
  };
@@ -67,7 +67,12 @@ async function saveLineup(client, ensemble, filePath, name) {
67
67
  message: entry.message,
68
68
  target: entry.target,
69
69
  };
70
- if (entry.interval) {
70
+ if (entry.cronExpression) {
71
+ sched.cron = entry.cronExpression;
72
+ if (entry.timezone)
73
+ sched.timezone = entry.timezone;
74
+ }
75
+ else if (entry.interval) {
71
76
  sched.every = formatDurationMs(entry.interval);
72
77
  }
73
78
  if (entry.until) {
@@ -12,6 +12,7 @@ export interface EnsembleLineup {
12
12
  workDir?: string;
13
13
  agent?: string;
14
14
  instructions?: string;
15
+ allowedTools?: string[];
15
16
  /** Transient: resolved agent definition name (set by loadAndResolveLineup). */
16
17
  _agentDefinition?: string;
17
18
  /** Transient: resolved absolute path to .md file (set by loadAndResolveLineup). */
@@ -24,6 +25,8 @@ export interface EnsembleLineup {
24
25
  at?: string;
25
26
  delay?: string;
26
27
  every?: string;
28
+ cron?: string;
29
+ timezone?: string;
27
30
  until?: string;
28
31
  count?: number;
29
32
  }>;
@@ -11,7 +11,10 @@ function registerAgentTypesTool(server) {
11
11
  }
12
12
  const lines = types.map(t => {
13
13
  const src = t.source === 'shipped' ? '(shipped)' : t.source === 'user' ? '(user)' : '(project)';
14
- return `**${t.name}** ${src}\n ${t.description || 'No description'}`;
14
+ const tools = t.allowedTools && t.allowedTools.length > 0
15
+ ? `\n Allowed tools: ${t.allowedTools.join(', ')}`
16
+ : '';
17
+ return `**${t.name}** ${src}\n ${t.description || 'No description'}${tools}`;
15
18
  });
16
19
  return { content: [{ type: 'text', text: lines.join('\n\n') }] };
17
20
  });
@@ -4,6 +4,7 @@ exports.registerLoadLineupTool = registerLoadLineupTool;
4
4
  const zod_1 = require("zod");
5
5
  const fs_1 = require("fs");
6
6
  const path_1 = require("path");
7
+ const croner_1 = require("croner");
7
8
  const client_1 = require("@temporalio/client");
8
9
  const config_1 = require("../config");
9
10
  const agent_types_1 = require("../ensemble/agent-types");
@@ -143,11 +144,16 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
143
144
  else if (systemPrompt) {
144
145
  agentFlags = ['--system-prompt', systemPrompt];
145
146
  }
147
+ // Build --allowedTools flag from agent definition or lineup
148
+ const allowedToolsFlags = player.allowedTools && player.allowedTools.length > 0
149
+ ? ['--allowedTools', ...player.allowedTools]
150
+ : [];
146
151
  const spawnArgs = [
147
152
  '--dangerously-skip-permissions',
148
153
  '--dangerously-load-development-channels', 'server:claude-tempo',
149
154
  '-n', playerName,
150
155
  ...agentFlags,
156
+ ...allowedToolsFlags,
151
157
  ];
152
158
  const envVars = {
153
159
  [config_1.ENV.ENSEMBLE]: config.ensemble,
@@ -212,8 +218,17 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
212
218
  const now = Date.now();
213
219
  let nextFireAt;
214
220
  let interval;
221
+ let cronExpression;
222
+ let timezone;
215
223
  if (sched.at) {
216
224
  nextFireAt = Date.parse(sched.at);
225
+ // Support at + every: use `at` as the initial fire time, `every` as the interval
226
+ if (sched.every) {
227
+ const ms = (0, duration_1.parseDuration)(sched.every);
228
+ if (!ms)
229
+ throw new Error(`Invalid interval: ${sched.every}`);
230
+ interval = ms;
231
+ }
217
232
  }
218
233
  else if (sched.delay) {
219
234
  const ms = (0, duration_1.parseDuration)(sched.delay);
@@ -228,16 +243,28 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
228
243
  nextFireAt = now + ms;
229
244
  interval = ms;
230
245
  }
246
+ else if (sched.cron) {
247
+ cronExpression = sched.cron;
248
+ timezone = sched.timezone || 'UTC';
249
+ const job = new croner_1.Cron(cronExpression, { timezone });
250
+ const next = job.nextRun();
251
+ if (!next)
252
+ throw new Error(`Cron expression "${sched.cron}" has no upcoming fire time`);
253
+ nextFireAt = next.getTime();
254
+ }
231
255
  else {
232
256
  throw new Error('No timing specified');
233
257
  }
258
+ const type = sched.cron ? 'cron' : (sched.every || interval) ? 'interval' : 'once';
234
259
  const scheduleEntry = {
235
260
  name: sched.name,
236
261
  message: sched.message,
237
262
  target: sched.target,
238
- type: sched.every ? 'interval' : 'once',
263
+ type,
239
264
  nextFireAt: new Date(nextFireAt).toISOString(),
240
265
  interval,
266
+ cronExpression,
267
+ timezone,
241
268
  until: sched.until,
242
269
  remainingCount: sched.count,
243
270
  firedCount: 0,
@@ -36,6 +36,7 @@ function registerRecruitTool(server, client, config, getPlayerId, handle, ownAge
36
36
  let agentDefinitionPath;
37
37
  let agentDefinitionDescription;
38
38
  let nativeResolvable;
39
+ let allowedTools;
39
40
  if (agentTypeName) {
40
41
  const info = (0, agent_types_1.resolveAgentType)(agentTypeName);
41
42
  if (!info) {
@@ -52,6 +53,7 @@ function registerRecruitTool(server, client, config, getPlayerId, handle, ownAge
52
53
  agentDefinitionPath = info.path;
53
54
  agentDefinitionDescription = info.description;
54
55
  nativeResolvable = info.nativeResolvable;
56
+ allowedTools = info.allowedTools;
55
57
  }
56
58
  // Validate name
57
59
  const nameError = (0, validation_1.validatePlayerName)(name);
@@ -118,6 +120,7 @@ function registerRecruitTool(server, client, config, getPlayerId, handle, ownAge
118
120
  agentDefinitionPath,
119
121
  agentDefinitionDescription,
120
122
  nativeResolvable,
123
+ allowedTools,
121
124
  };
122
125
  const entryId = await handle.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
123
126
  return {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerScheduleTool = registerScheduleTool;
4
4
  const zod_1 = require("zod");
5
+ const croner_1 = require("croner");
5
6
  const client_1 = require("@temporalio/client");
6
7
  const config_1 = require("../config");
7
8
  const duration_1 = require("../utils/duration");
@@ -9,29 +10,41 @@ const helpers_1 = require("./helpers");
9
10
  const validation_1 = require("../utils/validation");
10
11
  const log = (...args) => console.error('[claude-tempo:schedule]', ...args);
11
12
  function registerScheduleTool(server, client, config, getPlayerId) {
12
- (0, helpers_1.defineTool)(server, 'schedule', 'Schedule a message to be sent to a player at a specific time, after a delay, or on a recurring interval.', {
13
+ (0, helpers_1.defineTool)(server, 'schedule', 'Schedule a message to be sent to a player at a specific time, after a delay, on a recurring interval, or via cron expression.', {
13
14
  name: zod_1.z.string().max(validation_1.SCHEDULE_NAME_MAX).describe('Unique name for this schedule'),
14
15
  message: zod_1.z.string().max(validation_1.SCHEDULE_MESSAGE_MAX).describe('The message to deliver'),
15
16
  target: zod_1.z.string().max(validation_1.PLAYER_NAME_MAX).describe('Player name to deliver to ("self" = this session)'),
16
17
  at: zod_1.z.string().optional().describe('ISO datetime for one-shot delivery (e.g. "2026-04-03T20:00:00Z")'),
17
18
  delay: zod_1.z.string().optional().describe('Duration until first delivery (e.g. "10m", "2h", "1d")'),
18
19
  every: zod_1.z.string().optional().describe('Recurring interval (e.g. "5m", "1h")'),
20
+ cron: zod_1.z.string().max(validation_1.CRON_EXPRESSION_MAX).optional().describe('Cron expression for recurring delivery (e.g. "0 9 * * 1-5" = weekdays at 9am). Mutually exclusive with at/delay/every.'),
21
+ timezone: zod_1.z.string().optional().describe('IANA timezone for cron evaluation (e.g. "America/New_York"). Defaults to UTC. Only used with cron.'),
19
22
  until: zod_1.z.string().optional().describe('ISO datetime — stop recurring after this time'),
20
23
  count: zod_1.z.number().optional().describe('Max number of deliveries for recurring schedules'),
21
24
  }, async (args) => {
22
- const { name, message, at, delay, every, until, count } = args;
25
+ const { name, message, at, delay, every, cron, timezone, until, count } = args;
23
26
  let target = args.target;
24
27
  // Resolve "self" to the current player name
25
28
  if (target === 'self') {
26
29
  target = getPlayerId();
27
30
  }
28
31
  // Validate exactly one timing option
29
- const timingCount = [at, delay, every].filter(Boolean).length;
32
+ const timingCount = [at, delay, every, cron].filter(Boolean).length;
30
33
  if (timingCount !== 1) {
31
34
  return {
32
35
  content: [{
33
36
  type: 'text',
34
- text: 'Provide exactly one timing option: `at`, `delay`, or `every`.',
37
+ text: 'Provide exactly one timing option: `at`, `delay`, `every`, or `cron`.',
38
+ }],
39
+ isError: true,
40
+ };
41
+ }
42
+ // timezone only valid with cron
43
+ if (timezone && !cron) {
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: '`timezone` can only be used with `cron`.',
35
48
  }],
36
49
  isError: true,
37
50
  };
@@ -59,8 +72,8 @@ function registerScheduleTool(server, client, config, getPlayerId) {
59
72
  }
60
73
  nextFireAt = now + ms;
61
74
  }
62
- else {
63
- // every (recurring)
75
+ else if (every) {
76
+ // every (recurring interval)
64
77
  const ms = (0, duration_1.parseDuration)(every);
65
78
  if (ms === null || ms < 10_000) {
66
79
  return {
@@ -71,6 +84,27 @@ function registerScheduleTool(server, client, config, getPlayerId) {
71
84
  nextFireAt = now + ms;
72
85
  interval = ms;
73
86
  }
87
+ else {
88
+ // cron (recurring via cron expression)
89
+ try {
90
+ const job = new croner_1.Cron(cron, { timezone: timezone || 'UTC' });
91
+ const next = job.nextRun();
92
+ if (!next) {
93
+ return {
94
+ content: [{ type: 'text', text: `Cron expression "${cron}" has no upcoming fire time.` }],
95
+ isError: true,
96
+ };
97
+ }
98
+ nextFireAt = next.getTime();
99
+ }
100
+ catch (err) {
101
+ const msg = err instanceof Error ? err.message : String(err);
102
+ return {
103
+ content: [{ type: 'text', text: `Invalid cron expression "${cron}": ${msg}` }],
104
+ isError: true,
105
+ };
106
+ }
107
+ }
74
108
  // Parse optional until
75
109
  let untilMs;
76
110
  if (until) {
@@ -83,7 +117,7 @@ function registerScheduleTool(server, client, config, getPlayerId) {
83
117
  }
84
118
  untilMs = ts;
85
119
  }
86
- const type = every ? 'interval' : 'once';
120
+ const type = cron ? 'cron' : every ? 'interval' : 'once';
87
121
  const scheduleEntry = {
88
122
  name,
89
123
  message,
@@ -91,6 +125,8 @@ function registerScheduleTool(server, client, config, getPlayerId) {
91
125
  type,
92
126
  nextFireAt: new Date(nextFireAt).toISOString(),
93
127
  interval,
128
+ cronExpression: cron,
129
+ timezone: cron ? (timezone || 'UTC') : undefined,
94
130
  until: untilMs ? new Date(untilMs).toISOString() : undefined,
95
131
  remainingCount: count,
96
132
  firedCount: 0,
@@ -118,7 +154,11 @@ function registerScheduleTool(server, client, config, getPlayerId) {
118
154
  log(`Started scheduler workflow ${wfId}`);
119
155
  }
120
156
  const fireDate = new Date(nextFireAt).toISOString();
121
- const recur = interval ? ` (repeating every ${every})` : ' (one-shot)';
157
+ const recur = cron
158
+ ? ` (cron: ${cron}, tz: ${timezone || 'UTC'})`
159
+ : interval
160
+ ? ` (repeating every ${every})`
161
+ : ' (one-shot)';
122
162
  return {
123
163
  content: [{
124
164
  type: 'text',
@@ -39,7 +39,9 @@ function registerSchedulesTool(server, client, config) {
39
39
  }
40
40
  const lines = schedules.map((s) => {
41
41
  const next = s.nextFireAt; // already ISO string
42
- const recur = s.interval ? `every ${formatDuration(s.interval)}` : 'one-shot';
42
+ const recur = s.cronExpression
43
+ ? `cron: ${s.cronExpression} (${s.timezone || 'UTC'})`
44
+ : s.interval ? `every ${formatDuration(s.interval)}` : 'one-shot';
43
45
  const bounds = [];
44
46
  if (s.until)
45
47
  bounds.push(`until ${s.until}`);
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface AgentTypeInfo {
23
23
  source: 'project' | 'user' | 'shipped';
24
24
  path: string;
25
25
  nativeResolvable: boolean;
26
+ allowedTools?: string[];
26
27
  }
27
28
  export interface SessionInput {
28
29
  metadata: SessionMetadata;
@@ -110,6 +111,8 @@ export interface RecruitOutboxEntry extends OutboxEntryBase {
110
111
  agentDefinitionDescription?: string;
111
112
  /** Whether the agent definition is in a Claude Code-resolvable location. */
112
113
  nativeResolvable?: boolean;
114
+ /** Tool restrictions from the agent definition frontmatter. */
115
+ allowedTools?: string[];
113
116
  }
114
117
  export interface ReportOutboxEntry extends OutboxEntryBase {
115
118
  type: 'report';
@@ -151,6 +154,10 @@ export interface ScheduleEntry {
151
154
  /** Total number of times this schedule has fired. */
152
155
  firedCount: number;
153
156
  /** Schedule type for display purposes. */
154
- type: 'once' | 'interval';
157
+ type: 'once' | 'interval' | 'cron';
158
+ /** Cron expression string (e.g., "0 9 * * 1-5"). Stored for re-computing next fire. */
159
+ cronExpression?: string;
160
+ /** IANA timezone for cron evaluation (e.g., "America/New_York"). Defaults to UTC. */
161
+ timezone?: string;
155
162
  }
156
163
  export {};
@@ -20,6 +20,8 @@ export declare const PATH_MAX = 1024;
20
20
  export declare const SCHEDULE_NAME_MAX = 64;
21
21
  /** Maximum schedule message size (10KB). */
22
22
  export declare const SCHEDULE_MESSAGE_MAX = 10240;
23
+ /** Maximum cron expression length. */
24
+ export declare const CRON_EXPRESSION_MAX = 128;
23
25
  /** Default number of recent messages to include as context in an encore. */
24
26
  export declare const ENCORE_DEFAULT_CONTEXT_MESSAGES = 10;
25
27
  /** Maximum length for message preview truncation. */
@@ -4,7 +4,7 @@
4
4
  * Used by MCP tool Zod schemas and config validation.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.PREVIEW_MAX_LENGTH = exports.ENCORE_DEFAULT_CONTEXT_MESSAGES = exports.SCHEDULE_MESSAGE_MAX = exports.SCHEDULE_NAME_MAX = exports.PATH_MAX = exports.PART_MAX = exports.MESSAGE_MAX = exports.ENSEMBLE_NAME_REGEX = exports.PLAYER_NAME_MAX = exports.PLAYER_NAME_REGEX = void 0;
7
+ exports.PREVIEW_MAX_LENGTH = exports.ENCORE_DEFAULT_CONTEXT_MESSAGES = exports.CRON_EXPRESSION_MAX = exports.SCHEDULE_MESSAGE_MAX = exports.SCHEDULE_NAME_MAX = exports.PATH_MAX = exports.PART_MAX = exports.MESSAGE_MAX = exports.ENSEMBLE_NAME_REGEX = exports.PLAYER_NAME_MAX = exports.PLAYER_NAME_REGEX = void 0;
8
8
  exports.shouldIncludeInBroadcast = shouldIncludeInBroadcast;
9
9
  exports.validatePlayerName = validatePlayerName;
10
10
  exports.validateEnsembleName = validateEnsembleName;
@@ -26,6 +26,8 @@ exports.PATH_MAX = 1024;
26
26
  exports.SCHEDULE_NAME_MAX = 64;
27
27
  /** Maximum schedule message size (10KB). */
28
28
  exports.SCHEDULE_MESSAGE_MAX = 10240;
29
+ /** Maximum cron expression length. */
30
+ exports.CRON_EXPRESSION_MAX = 128;
29
31
  /** Default number of recent messages to include as context in an encore. */
30
32
  exports.ENCORE_DEFAULT_CONTEXT_MESSAGES = 10;
31
33
  /** Maximum length for message preview truncation. */
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.claudeSchedulerWorkflow = claudeSchedulerWorkflow;
4
4
  const workflow_1 = require("@temporalio/workflow");
5
5
  const scheduler_signals_1 = require("./scheduler-signals");
6
- const { fireSchedule } = (0, workflow_1.proxyActivities)({
6
+ const { fireSchedule, computeNextCronFire } = (0, workflow_1.proxyActivities)({
7
7
  startToCloseTimeout: '30 seconds',
8
8
  retry: { maximumAttempts: 3 },
9
9
  });
@@ -83,6 +83,18 @@ async function claudeSchedulerWorkflow(input) {
83
83
  else if (entry.type === 'interval' && entry.interval) {
84
84
  entry.nextFireAt = new Date(Date.now() + entry.interval).toISOString();
85
85
  }
86
+ else if ((0, workflow_1.patched)('v0.12-cron-schedule') && entry.type === 'cron' && entry.cronExpression) {
87
+ const nextFire = await computeNextCronFire({
88
+ cronExpression: entry.cronExpression,
89
+ timezone: entry.timezone,
90
+ });
91
+ if (nextFire) {
92
+ entry.nextFireAt = nextFire;
93
+ }
94
+ else {
95
+ entries = entries.filter((e) => e.name !== entry.name);
96
+ }
97
+ }
86
98
  }
87
99
  // Prevent unbounded history growth
88
100
  const info = (0, workflow_1.workflowInfo)();
@@ -258,6 +258,7 @@ async function claudeSessionWorkflow(input) {
258
258
  taskQueue: tc?.taskQueue || 'claude-tempo',
259
259
  agentDefinition: entry.agentDefinition,
260
260
  agentDefinitionDescription: entry.agentDefinitionDescription,
261
+ allowedTools: entry.allowedTools,
261
262
  });
262
263
  const targetHost = entry.targetHostname || input.metadata.hostname;
263
264
  const spawnFn = getSpawnProxy(targetHost);
@@ -273,6 +274,7 @@ async function claudeSessionWorkflow(input) {
273
274
  agentDefinition: entry.agentDefinition,
274
275
  agentDefinitionPath: entry.agentDefinitionPath,
275
276
  nativeResolvable: entry.nativeResolvable,
277
+ allowedTools: entry.allowedTools,
276
278
  });
277
279
  break;
278
280
  }
@@ -297,6 +299,7 @@ async function claudeSessionWorkflow(input) {
297
299
  agentDefinition: encoreResult.agentDefinition,
298
300
  agentDefinitionPath: encoreResult.agentDefinitionPath,
299
301
  nativeResolvable: encoreResult.nativeResolvable,
302
+ allowedTools: encoreResult.allowedTools,
300
303
  resume: true,
301
304
  });
302
305
  }