claude-tempo 0.5.0 → 0.6.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/README.md +43 -3
- package/dist/activities/schedule-fire.d.ts +21 -0
- package/dist/activities/schedule-fire.js +93 -0
- package/dist/cli/commands.js +46 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/server.js +11 -3
- package/dist/tools/recruit.js +5 -14
- package/dist/tools/schedule.d.ts +4 -0
- package/dist/tools/schedule.js +148 -0
- package/dist/tools/schedules.d.ts +4 -0
- package/dist/tools/schedules.js +66 -0
- package/dist/tools/unschedule.d.ts +4 -0
- package/dist/tools/unschedule.js +30 -0
- package/dist/types.d.ts +22 -0
- package/dist/worker.js +9 -1
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +8 -0
- package/dist/workflows/scheduler-signals.d.ts +6 -0
- package/dist/workflows/scheduler-signals.js +10 -0
- package/dist/workflows/scheduler.d.ts +7 -0
- package/dist/workflows/scheduler.js +99 -0
- package/package.json +2 -2
- package/workflow-bundle.js +155 -5
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ claude-tempo <command> [options]
|
|
|
94
94
|
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
|
|
95
95
|
| `down` | Stop Temporal, terminate sessions, remove MCP config |
|
|
96
96
|
| `server` | Start the Temporal dev server and register search attributes |
|
|
97
|
-
| `conduct [ensemble]` | Start a conductor session (one per ensemble) |
|
|
97
|
+
| `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
|
|
98
98
|
| `start [ensemble]` | Start a player session |
|
|
99
99
|
| `status [ensemble]` | Show active sessions and Temporal health |
|
|
100
100
|
| `config` | Configure Temporal connection settings (interactive or `set`/`show`) |
|
|
@@ -115,6 +115,8 @@ claude-tempo <command> [options]
|
|
|
115
115
|
--skip-preflight Skip preflight checks (start/conduct)
|
|
116
116
|
-d, --dir <path> Target directory (default: cwd)
|
|
117
117
|
--background Run Temporal in background (server only)
|
|
118
|
+
--resume Resume an existing conductor session (conduct only)
|
|
119
|
+
--replace Stop existing conductor and start fresh (conduct only)
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
### `claude-tempo up`
|
|
@@ -173,6 +175,9 @@ Ensemble: myband
|
|
|
173
175
|
bob
|
|
174
176
|
Working on the dashboard
|
|
175
177
|
/Users/me/projects/app feat/ui my-machine.local
|
|
178
|
+
|
|
179
|
+
1 active schedule
|
|
180
|
+
deploy-watch → ops | every 1h | next: 3:00:00 PM
|
|
176
181
|
```
|
|
177
182
|
|
|
178
183
|
### `claude-tempo preflight`
|
|
@@ -201,9 +206,43 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
|
|
|
201
206
|
| `set_name` | Set a human-readable name for this session. |
|
|
202
207
|
| `set_part` | Describe what you're working on. Visible to others via `ensemble`. |
|
|
203
208
|
| `listen` | Manually check for pending messages. |
|
|
204
|
-
| `recruit` | Spawn a new Claude Code session in a directory.
|
|
209
|
+
| `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
|
|
205
210
|
| `report` | Send updates to the conductor. No-op if no conductor exists. |
|
|
206
211
|
| `terminate` | Terminate a player session by name. |
|
|
212
|
+
| `schedule` | Create a one-shot or recurring schedule to cue a player. |
|
|
213
|
+
| `unschedule` | Cancel a named schedule. |
|
|
214
|
+
| `schedules` | List all active schedules. |
|
|
215
|
+
|
|
216
|
+
## Scheduling
|
|
217
|
+
|
|
218
|
+
Players can set up schedules to send messages on timers — useful for periodic checks, reminders, and recurring coordination.
|
|
219
|
+
|
|
220
|
+
Three tools are available:
|
|
221
|
+
- **`schedule`** — Create a named schedule (one-shot or recurring)
|
|
222
|
+
- **`unschedule`** — Remove a schedule by name
|
|
223
|
+
- **`schedules`** — List all active schedules
|
|
224
|
+
|
|
225
|
+
### Examples
|
|
226
|
+
|
|
227
|
+
Tell your session things like:
|
|
228
|
+
|
|
229
|
+
- *"Schedule a check every hour called 'deploy-watch' — cue ops to check deployment status"*
|
|
230
|
+
- *"Remind me in 30 minutes to review PR #42"*
|
|
231
|
+
- *"Every 5 minutes for the next hour, ping frontend to check their progress"*
|
|
232
|
+
- *"Set up a daily standup reminder at 9am UTC for the conductor"*
|
|
233
|
+
- *"Cancel the deploy-watch schedule"*
|
|
234
|
+
- *"Show me all active schedules"*
|
|
235
|
+
|
|
236
|
+
Schedules support one-shot delays, fixed times, and recurring intervals with optional bounds (max count or end time).
|
|
237
|
+
|
|
238
|
+
### How it works
|
|
239
|
+
|
|
240
|
+
- Scheduled messages arrive with a `[scheduled: name]` prefix so recipients can distinguish them from direct cues
|
|
241
|
+
- The `from` field is set to the schedule creator, so replies go to the right person
|
|
242
|
+
- If the target player is gone when a schedule fires, the creator is notified so they can re-recruit if needed. Falls back to notifying the conductor if the creator is also unavailable
|
|
243
|
+
- Messages include `isScheduled` metadata for dashboard integrations
|
|
244
|
+
- `claude-tempo status` shows active schedules alongside sessions
|
|
245
|
+
- A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
|
|
207
246
|
|
|
208
247
|
## Conductors
|
|
209
248
|
|
|
@@ -274,11 +313,12 @@ Inside a session, try:
|
|
|
274
313
|
|
|
275
314
|
Sessions start with a random 8-character hex ID. Set a name at launch with `-n` or use `set_name` inside a session.
|
|
276
315
|
|
|
277
|
-
- Names are stored
|
|
316
|
+
- Names are stored in workflow metadata and discoverable via metadata queries. Search attributes are also set for Temporal UI visibility.
|
|
278
317
|
- Other players use names to send messages via `cue`
|
|
279
318
|
- `recruit` automatically tells new sessions to set their name
|
|
280
319
|
- Names must be unique within an ensemble
|
|
281
320
|
- Names must contain only letters, numbers, hyphens, and underscores
|
|
321
|
+
- The name "conductor" is reserved for conductor sessions
|
|
282
322
|
|
|
283
323
|
### Terminal support
|
|
284
324
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Client } from '@temporalio/client';
|
|
2
|
+
export interface FireScheduleInput {
|
|
3
|
+
ensemble: string;
|
|
4
|
+
scheduleName: string;
|
|
5
|
+
message: string;
|
|
6
|
+
target: string;
|
|
7
|
+
createdBy: string;
|
|
8
|
+
}
|
|
9
|
+
export interface FireScheduleResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Activity interface — used by proxyActivities in the scheduler workflow. */
|
|
14
|
+
export interface ScheduleActivities {
|
|
15
|
+
fireSchedule(input: FireScheduleInput): Promise<FireScheduleResult>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create the schedule-fire activity bound to a Temporal client.
|
|
19
|
+
* The returned object is registered with the worker as activities.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createScheduleActivities(client: Client): ScheduleActivities;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createScheduleActivities = createScheduleActivities;
|
|
4
|
+
/**
|
|
5
|
+
* Create the schedule-fire activity bound to a Temporal client.
|
|
6
|
+
* The returned object is registered with the worker as activities.
|
|
7
|
+
*/
|
|
8
|
+
function createScheduleActivities(client) {
|
|
9
|
+
return {
|
|
10
|
+
async fireSchedule(input) {
|
|
11
|
+
const { ensemble, scheduleName, message, target, createdBy } = input;
|
|
12
|
+
try {
|
|
13
|
+
// Resolve target player by querying running session workflows
|
|
14
|
+
const handle = await resolveSession(client, ensemble, target);
|
|
15
|
+
if (!handle) {
|
|
16
|
+
// Notify the creator (or conductor as fallback) about the failure
|
|
17
|
+
await notifyFailure(client, ensemble, createdBy, scheduleName, target, `Player "${target}" not found — session may have been terminated.`);
|
|
18
|
+
return { success: false, error: `No active session found for "${target}"` };
|
|
19
|
+
}
|
|
20
|
+
// Send cue signal with from set to the original creator's name
|
|
21
|
+
const text = `[scheduled: ${scheduleName}] ${message}`;
|
|
22
|
+
await handle.signal('receiveMessage', {
|
|
23
|
+
from: createdBy,
|
|
24
|
+
text,
|
|
25
|
+
isScheduled: true,
|
|
26
|
+
scheduleName,
|
|
27
|
+
});
|
|
28
|
+
return { success: true };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { success: false, error: errorMsg };
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Notify the schedule creator (or conductor as fallback) that delivery failed.
|
|
39
|
+
* This lets the creator's AI session decide whether to re-recruit the target.
|
|
40
|
+
*/
|
|
41
|
+
async function notifyFailure(client, ensemble, createdBy, scheduleName, target, reason) {
|
|
42
|
+
const failureText = `[scheduled: ${scheduleName}] Delivery to "${target}" failed — ${reason}`;
|
|
43
|
+
// Try the creator first
|
|
44
|
+
const creatorHandle = await resolveSession(client, ensemble, createdBy);
|
|
45
|
+
if (creatorHandle) {
|
|
46
|
+
try {
|
|
47
|
+
await creatorHandle.signal('receiveMessage', {
|
|
48
|
+
from: 'scheduler',
|
|
49
|
+
text: failureText,
|
|
50
|
+
isScheduled: true,
|
|
51
|
+
scheduleName,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// creator signal failed, fall through to conductor
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Fallback: notify the conductor
|
|
60
|
+
try {
|
|
61
|
+
const conductorId = `claude-session-${ensemble}-conductor`;
|
|
62
|
+
const conductorHandle = client.workflow.getHandle(conductorId);
|
|
63
|
+
await conductorHandle.signal('receiveMessage', {
|
|
64
|
+
from: 'scheduler',
|
|
65
|
+
text: failureText,
|
|
66
|
+
isScheduled: true,
|
|
67
|
+
scheduleName,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Nobody available to notify — logged by the workflow
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a session by player name — mirrors src/tools/resolve.ts logic.
|
|
76
|
+
* We duplicate here because activities run in Node.js and need their own copy.
|
|
77
|
+
*/
|
|
78
|
+
async function resolveSession(client, ensemble, playerName) {
|
|
79
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
80
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
81
|
+
try {
|
|
82
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
83
|
+
const metadata = await handle.query('getMetadata');
|
|
84
|
+
if (metadata.ensemble === ensemble && metadata.playerId === playerName) {
|
|
85
|
+
return handle;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Workflow may have just completed — skip
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -55,6 +55,15 @@ const mcp_1 = require("./mcp");
|
|
|
55
55
|
const out = __importStar(require("./output"));
|
|
56
56
|
/** Package root is two levels up from dist/cli/ */
|
|
57
57
|
const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
|
|
58
|
+
function formatDurationMs(ms) {
|
|
59
|
+
if (ms >= 86_400_000)
|
|
60
|
+
return `${ms / 86_400_000}d`;
|
|
61
|
+
if (ms >= 3_600_000)
|
|
62
|
+
return `${ms / 3_600_000}h`;
|
|
63
|
+
if (ms >= 60_000)
|
|
64
|
+
return `${ms / 60_000}m`;
|
|
65
|
+
return `${ms / 1000}s`;
|
|
66
|
+
}
|
|
58
67
|
async function start(opts) {
|
|
59
68
|
const config = (0, config_1.getConfig)(opts);
|
|
60
69
|
const workDir = opts.dir || process.cwd();
|
|
@@ -221,8 +230,27 @@ async function status(opts) {
|
|
|
221
230
|
// workflow may have closed between list and query
|
|
222
231
|
}
|
|
223
232
|
}
|
|
233
|
+
// Query scheduler workflows for active schedules
|
|
234
|
+
const schedulesByEnsemble = new Map();
|
|
235
|
+
const schedulerQuery = 'WorkflowType = "claudeSchedulerWorkflow" AND ExecutionStatus = "Running"';
|
|
236
|
+
for await (const wf of client.workflow.list({ query: schedulerQuery })) {
|
|
237
|
+
try {
|
|
238
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
239
|
+
const entries = await handle.query('getSchedules');
|
|
240
|
+
if (entries.length > 0) {
|
|
241
|
+
// Extract ensemble from workflow ID: claude-scheduler-{ensemble}
|
|
242
|
+
const ensemble = wf.workflowId.replace('claude-scheduler-', '');
|
|
243
|
+
if (opts.ensemble && ensemble !== opts.ensemble)
|
|
244
|
+
continue;
|
|
245
|
+
schedulesByEnsemble.set(ensemble, entries);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// scheduler may have just completed
|
|
250
|
+
}
|
|
251
|
+
}
|
|
224
252
|
await connection.close();
|
|
225
|
-
if (sessions.length === 0) {
|
|
253
|
+
if (sessions.length === 0 && schedulesByEnsemble.size === 0) {
|
|
226
254
|
out.log(opts.ensemble
|
|
227
255
|
? `No active sessions in ensemble "${opts.ensemble}".`
|
|
228
256
|
: 'No active sessions found.');
|
|
@@ -256,6 +284,23 @@ async function status(opts) {
|
|
|
256
284
|
if (details)
|
|
257
285
|
out.log(` ${out.dim(details)}`);
|
|
258
286
|
}
|
|
287
|
+
// Show schedules for this ensemble
|
|
288
|
+
const ensembleSchedules = schedulesByEnsemble.get(ensemble);
|
|
289
|
+
if (ensembleSchedules && ensembleSchedules.length > 0) {
|
|
290
|
+
console.log();
|
|
291
|
+
out.log(` ${out.dim(`${ensembleSchedules.length} active schedule${ensembleSchedules.length !== 1 ? 's' : ''}`)}`);
|
|
292
|
+
for (const sched of ensembleSchedules) {
|
|
293
|
+
const recur = sched.interval
|
|
294
|
+
? `every ${formatDurationMs(sched.interval)}`
|
|
295
|
+
: 'one-shot';
|
|
296
|
+
const next = new Date(sched.nextFireAt).toLocaleTimeString();
|
|
297
|
+
const bounds = [];
|
|
298
|
+
if (sched.remainingCount != null)
|
|
299
|
+
bounds.push(`${sched.firedCount}/${sched.firedCount + sched.remainingCount} fired`);
|
|
300
|
+
const boundsStr = bounds.length ? ` (${bounds.join(', ')})` : '';
|
|
301
|
+
out.log(` ${out.bold(sched.name)} → ${sched.target} | ${recur}${boundsStr} | next: ${next}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
259
304
|
}
|
|
260
305
|
console.log();
|
|
261
306
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -89,3 +89,5 @@ export declare function getConfigWithSources(overrides?: CliOverrides): ConfigWi
|
|
|
89
89
|
export declare function sessionWorkflowId(ensemble: string, playerId: string): string;
|
|
90
90
|
/** Build a workflow ID for a conductor: claude-session-{ensemble}-conductor */
|
|
91
91
|
export declare function conductorWorkflowId(ensemble: string): string;
|
|
92
|
+
/** Build a workflow ID for the scheduler: claude-scheduler-{ensemble} */
|
|
93
|
+
export declare function schedulerWorkflowId(ensemble: string): string;
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.getConfig = getConfig;
|
|
|
9
9
|
exports.getConfigWithSources = getConfigWithSources;
|
|
10
10
|
exports.sessionWorkflowId = sessionWorkflowId;
|
|
11
11
|
exports.conductorWorkflowId = conductorWorkflowId;
|
|
12
|
+
exports.schedulerWorkflowId = schedulerWorkflowId;
|
|
12
13
|
const fs_1 = require("fs");
|
|
13
14
|
const path_1 = require("path");
|
|
14
15
|
const os_1 = require("os");
|
|
@@ -246,3 +247,7 @@ function sessionWorkflowId(ensemble, playerId) {
|
|
|
246
247
|
function conductorWorkflowId(ensemble) {
|
|
247
248
|
return `claude-session-${ensemble}-conductor`;
|
|
248
249
|
}
|
|
250
|
+
/** Build a workflow ID for the scheduler: claude-scheduler-{ensemble} */
|
|
251
|
+
function schedulerWorkflowId(ensemble) {
|
|
252
|
+
return `claude-scheduler-${ensemble}`;
|
|
253
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -52,6 +52,9 @@ const recruit_1 = require("./tools/recruit");
|
|
|
52
52
|
const report_1 = require("./tools/report");
|
|
53
53
|
const terminate_1 = require("./tools/terminate");
|
|
54
54
|
const set_name_1 = require("./tools/set-name");
|
|
55
|
+
const schedule_1 = require("./tools/schedule");
|
|
56
|
+
const unschedule_1 = require("./tools/unschedule");
|
|
57
|
+
const schedules_1 = require("./tools/schedules");
|
|
55
58
|
const channel_1 = require("./channel");
|
|
56
59
|
const log = (...args) => console.error('[claude-tempo]', ...args);
|
|
57
60
|
function getGitInfo(workDir) {
|
|
@@ -160,11 +163,13 @@ async function main() {
|
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
// Create MCP server
|
|
166
|
+
const hasRequestedName = Boolean(requestedName && requestedName !== 'conductor');
|
|
163
167
|
const serverInstructions = `You are part of the "${config.ensemble}" ensemble of Claude Code sessions coordinated via Temporal. ` +
|
|
164
|
-
`Your
|
|
165
|
-
|
|
168
|
+
`Your player name is "${playerId}". ` +
|
|
169
|
+
(hasRequestedName
|
|
170
|
+
? `This name was assigned at startup — do NOT call \`set_name\` unless explicitly asked to rename. `
|
|
171
|
+
: `IMPORTANT: If you receive a message instructing you to call \`set_name\`, do so immediately before anything else. Use \`set_name\` to give yourself a human-readable name. `) +
|
|
166
172
|
`When you receive a message from another session, treat it like a coworker asking for help — respond promptly, then resume your work. ` +
|
|
167
|
-
`Use \`set_name\` to give yourself a human-readable name. ` +
|
|
168
173
|
`Use \`ensemble\` to see who else is active. ` +
|
|
169
174
|
`Use \`cue\` to reply directly to the player who messaged you, or to ask others for help. ` +
|
|
170
175
|
`Use \`recruit\` if you need a session in a directory where none exists. ` +
|
|
@@ -187,6 +192,9 @@ async function main() {
|
|
|
187
192
|
(0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
|
|
188
193
|
(0, report_1.registerReportTool)(mcpServer, client, config, getPlayerId);
|
|
189
194
|
(0, terminate_1.registerTerminateTool)(mcpServer, client, config, getPlayerId);
|
|
195
|
+
(0, schedule_1.registerScheduleTool)(mcpServer, client, config, getPlayerId);
|
|
196
|
+
(0, unschedule_1.registerUnscheduleTool)(mcpServer, client, config);
|
|
197
|
+
(0, schedules_1.registerSchedulesTool)(mcpServer, client, config);
|
|
190
198
|
const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
|
|
191
199
|
// Start message poller — push messages into Claude Code via channel notifications.
|
|
192
200
|
// Skip when running under the Copilot bridge: the bridge has its own poller that
|
package/dist/tools/recruit.js
CHANGED
|
@@ -140,20 +140,11 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
|
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
142
|
const newHandle = client.workflow.getHandle(newWorkflowId);
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
? `${nameInstruction}\n\nThen: ${initialMessage}`
|
|
149
|
-
: nameInstruction;
|
|
150
|
-
await newHandle.signal('receiveMessage', {
|
|
151
|
-
from: getPlayerId(),
|
|
152
|
-
text: fullMessage,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
else if (initialMessage) {
|
|
156
|
-
// For copilot, just send the initial task (name is set by the bridge)
|
|
143
|
+
// Name is already set via CLAUDE_TEMPO_PLAYER_NAME env var at startup,
|
|
144
|
+
// so we only need to send the initial task message if provided.
|
|
145
|
+
// (Previously we sent a set_name instruction here, but that was redundant
|
|
146
|
+
// and could cause confusion if the LLM renamed itself incorrectly.)
|
|
147
|
+
if (initialMessage) {
|
|
157
148
|
await newHandle.signal('receiveMessage', {
|
|
158
149
|
from: getPlayerId(),
|
|
159
150
|
text: initialMessage,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Client } from '@temporalio/client';
|
|
3
|
+
import { Config } from '../config';
|
|
4
|
+
export declare function registerScheduleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string): void;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerScheduleTool = registerScheduleTool;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const client_1 = require("@temporalio/client");
|
|
6
|
+
const config_1 = require("../config");
|
|
7
|
+
const helpers_1 = require("./helpers");
|
|
8
|
+
const log = (...args) => console.error('[claude-tempo:schedule]', ...args);
|
|
9
|
+
/** Parse a duration string like "30s", "10m", "2h", "1d" into milliseconds. */
|
|
10
|
+
function parseDuration(dur) {
|
|
11
|
+
const match = dur.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
const value = parseFloat(match[1]);
|
|
15
|
+
switch (match[2].toLowerCase()) {
|
|
16
|
+
case 's': return value * 1000;
|
|
17
|
+
case 'm': return value * 60_000;
|
|
18
|
+
case 'h': return value * 3_600_000;
|
|
19
|
+
case 'd': return value * 86_400_000;
|
|
20
|
+
default: return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function registerScheduleTool(server, client, config, getPlayerId) {
|
|
24
|
+
(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.', {
|
|
25
|
+
name: zod_1.z.string().describe('Unique name for this schedule'),
|
|
26
|
+
message: zod_1.z.string().describe('The message to deliver'),
|
|
27
|
+
target: zod_1.z.string().describe('Player name to deliver to ("self" = this session)'),
|
|
28
|
+
at: zod_1.z.string().optional().describe('ISO datetime for one-shot delivery (e.g. "2026-04-03T20:00:00Z")'),
|
|
29
|
+
delay: zod_1.z.string().optional().describe('Duration until first delivery (e.g. "10m", "2h", "1d")'),
|
|
30
|
+
every: zod_1.z.string().optional().describe('Recurring interval (e.g. "5m", "1h")'),
|
|
31
|
+
until: zod_1.z.string().optional().describe('ISO datetime — stop recurring after this time'),
|
|
32
|
+
count: zod_1.z.number().optional().describe('Max number of deliveries for recurring schedules'),
|
|
33
|
+
}, async (args) => {
|
|
34
|
+
const { name, message, at, delay, every, until, count } = args;
|
|
35
|
+
let target = args.target;
|
|
36
|
+
// Resolve "self" to the current player name
|
|
37
|
+
if (target === 'self') {
|
|
38
|
+
target = getPlayerId();
|
|
39
|
+
}
|
|
40
|
+
// Validate exactly one timing option
|
|
41
|
+
const timingCount = [at, delay, every].filter(Boolean).length;
|
|
42
|
+
if (timingCount !== 1) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: 'Provide exactly one timing option: `at`, `delay`, or `every`.',
|
|
47
|
+
}],
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
let nextFireAt;
|
|
53
|
+
let interval;
|
|
54
|
+
if (at) {
|
|
55
|
+
const ts = Date.parse(at);
|
|
56
|
+
if (isNaN(ts)) {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: `Invalid ISO datetime for "at": ${at}` }],
|
|
59
|
+
isError: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
nextFireAt = ts;
|
|
63
|
+
}
|
|
64
|
+
else if (delay) {
|
|
65
|
+
const ms = parseDuration(delay);
|
|
66
|
+
if (ms === null) {
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: 'text', text: `Invalid duration for "delay": ${delay}. Use e.g. "30s", "10m", "2h", "1d".` }],
|
|
69
|
+
isError: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
nextFireAt = now + ms;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// every (recurring)
|
|
76
|
+
const ms = parseDuration(every);
|
|
77
|
+
if (ms === null || ms < 10_000) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: 'text', text: `Invalid or too-short interval for "every": ${every}. Minimum is 10s.` }],
|
|
80
|
+
isError: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
nextFireAt = now + ms;
|
|
84
|
+
interval = ms;
|
|
85
|
+
}
|
|
86
|
+
// Parse optional until
|
|
87
|
+
let untilMs;
|
|
88
|
+
if (until) {
|
|
89
|
+
const ts = Date.parse(until);
|
|
90
|
+
if (isNaN(ts)) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: 'text', text: `Invalid ISO datetime for "until": ${until}` }],
|
|
93
|
+
isError: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
untilMs = ts;
|
|
97
|
+
}
|
|
98
|
+
const type = every ? 'interval' : 'once';
|
|
99
|
+
const scheduleEntry = {
|
|
100
|
+
name,
|
|
101
|
+
message,
|
|
102
|
+
target,
|
|
103
|
+
type,
|
|
104
|
+
nextFireAt: new Date(nextFireAt).toISOString(),
|
|
105
|
+
interval,
|
|
106
|
+
until: untilMs ? new Date(untilMs).toISOString() : undefined,
|
|
107
|
+
remainingCount: count,
|
|
108
|
+
firedCount: 0,
|
|
109
|
+
createdBy: getPlayerId(),
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
const wfId = (0, config_1.schedulerWorkflowId)(config.ensemble);
|
|
113
|
+
// Try to signal the existing scheduler workflow
|
|
114
|
+
try {
|
|
115
|
+
const handle = client.workflow.getHandle(wfId);
|
|
116
|
+
await handle.describe(); // throws if not running
|
|
117
|
+
await handle.signal('addSchedule', scheduleEntry);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Scheduler not running — start it with this schedule as seed
|
|
121
|
+
await client.workflow.start('claudeSchedulerWorkflow', {
|
|
122
|
+
workflowId: wfId,
|
|
123
|
+
taskQueue: config.taskQueue,
|
|
124
|
+
args: [{ ensemble: config.ensemble, entries: [scheduleEntry] }],
|
|
125
|
+
workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
|
|
126
|
+
searchAttributes: {
|
|
127
|
+
ClaudeTempoEnsemble: [config.ensemble],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
log(`Started scheduler workflow ${wfId}`);
|
|
131
|
+
}
|
|
132
|
+
const fireDate = new Date(nextFireAt).toISOString();
|
|
133
|
+
const recur = interval ? ` (repeating every ${every})` : ' (one-shot)';
|
|
134
|
+
return {
|
|
135
|
+
content: [{
|
|
136
|
+
type: 'text',
|
|
137
|
+
text: `Schedule **${name}** created. Next fire: ${fireDate}${recur}. Target: ${target}.`,
|
|
138
|
+
}],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: 'text', text: `Failed to create schedule: ${err}` }],
|
|
144
|
+
isError: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSchedulesTool = registerSchedulesTool;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const helpers_1 = require("./helpers");
|
|
6
|
+
function formatDuration(ms) {
|
|
7
|
+
if (ms >= 86_400_000)
|
|
8
|
+
return `${ms / 86_400_000}d`;
|
|
9
|
+
if (ms >= 3_600_000)
|
|
10
|
+
return `${ms / 3_600_000}h`;
|
|
11
|
+
if (ms >= 60_000)
|
|
12
|
+
return `${ms / 60_000}m`;
|
|
13
|
+
return `${ms / 1000}s`;
|
|
14
|
+
}
|
|
15
|
+
function registerSchedulesTool(server, client, config) {
|
|
16
|
+
(0, helpers_1.defineTool)(server, 'schedules', 'List all active schedules in this ensemble.', {}, async () => {
|
|
17
|
+
try {
|
|
18
|
+
const wfId = (0, config_1.schedulerWorkflowId)(config.ensemble);
|
|
19
|
+
const handle = client.workflow.getHandle(wfId);
|
|
20
|
+
let schedules;
|
|
21
|
+
try {
|
|
22
|
+
schedules = await handle.query('getSchedules');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {
|
|
26
|
+
content: [{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: 'No scheduler running — no schedules exist yet.',
|
|
29
|
+
}],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (schedules.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{
|
|
35
|
+
type: 'text',
|
|
36
|
+
text: 'No active schedules.',
|
|
37
|
+
}],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const lines = schedules.map((s) => {
|
|
41
|
+
const next = s.nextFireAt; // already ISO string
|
|
42
|
+
const recur = s.interval ? `every ${formatDuration(s.interval)}` : 'one-shot';
|
|
43
|
+
const bounds = [];
|
|
44
|
+
if (s.until)
|
|
45
|
+
bounds.push(`until ${s.until}`);
|
|
46
|
+
if (s.remainingCount != null)
|
|
47
|
+
bounds.push(`${s.firedCount}/${s.firedCount + s.remainingCount} fired`);
|
|
48
|
+
const boundsStr = bounds.length ? ` (${bounds.join(', ')})` : '';
|
|
49
|
+
const msgPreview = s.message.length > 60 ? s.message.slice(0, 57) + '...' : s.message;
|
|
50
|
+
return `• **${s.name}** → ${s.target} | ${recur}${boundsStr} | next: ${next}\n msg: ${msgPreview}`;
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: lines.join('\n'),
|
|
56
|
+
}],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: 'text', text: `Failed to query schedules: ${err}` }],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerUnscheduleTool = registerUnscheduleTool;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const config_1 = require("../config");
|
|
6
|
+
const helpers_1 = require("./helpers");
|
|
7
|
+
function registerUnscheduleTool(server, client, config) {
|
|
8
|
+
(0, helpers_1.defineTool)(server, 'unschedule', 'Remove a named schedule. The schedule stops firing immediately.', {
|
|
9
|
+
name: zod_1.z.string().describe('Name of the schedule to remove'),
|
|
10
|
+
}, async (args) => {
|
|
11
|
+
const { name } = args;
|
|
12
|
+
try {
|
|
13
|
+
const wfId = (0, config_1.schedulerWorkflowId)(config.ensemble);
|
|
14
|
+
const handle = client.workflow.getHandle(wfId);
|
|
15
|
+
await handle.signal('removeSchedule', name);
|
|
16
|
+
return {
|
|
17
|
+
content: [{
|
|
18
|
+
type: 'text',
|
|
19
|
+
text: `Schedule **${name}** removed.`,
|
|
20
|
+
}],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: 'text', text: `Failed to remove schedule: ${err}` }],
|
|
26
|
+
isError: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -57,3 +57,25 @@ export interface HistoryEntry {
|
|
|
57
57
|
timestamp: string;
|
|
58
58
|
data: Command | PlayerReport;
|
|
59
59
|
}
|
|
60
|
+
export interface ScheduleEntry {
|
|
61
|
+
/** Unique name for this schedule (used as key for add/replace/remove). */
|
|
62
|
+
name: string;
|
|
63
|
+
/** The message text to deliver when the schedule fires. */
|
|
64
|
+
message: string;
|
|
65
|
+
/** Target player name to deliver the cue to. */
|
|
66
|
+
target: string;
|
|
67
|
+
/** Player name of whoever created this schedule. */
|
|
68
|
+
createdBy: string;
|
|
69
|
+
/** ISO timestamp of the next fire time. */
|
|
70
|
+
nextFireAt: string;
|
|
71
|
+
/** Interval in milliseconds for repeating schedules. */
|
|
72
|
+
interval?: number;
|
|
73
|
+
/** ISO timestamp after which the schedule should be removed. */
|
|
74
|
+
until?: string;
|
|
75
|
+
/** Number of remaining fires (decremented each fire, removed at 0). */
|
|
76
|
+
remainingCount?: number;
|
|
77
|
+
/** Total number of times this schedule has fired. */
|
|
78
|
+
firedCount: number;
|
|
79
|
+
/** Schedule type for display purposes. */
|
|
80
|
+
type: 'once' | 'interval';
|
|
81
|
+
}
|