claude-tempo 0.1.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.
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Client } from '@temporalio/client';
4
+ import * as os from 'os';
5
+ import { Config } from '../config';
6
+ import { SessionMetadata } from '../types';
7
+ import { defineTool } from './helpers';
8
+
9
+ export function registerEnsembleTool(
10
+ server: McpServer,
11
+ client: Client,
12
+ config: Config,
13
+ getPlayerId: () => string,
14
+ ownWorkflowId: string,
15
+ ) {
16
+ defineTool(
17
+ server,
18
+ 'ensemble',
19
+ `Discover active Claude Code sessions in the "${config.ensemble}" ensemble. Returns player IDs, descriptions, and metadata.`,
20
+ {
21
+ scope: z.string().optional().describe('Filter scope: "machine" (same hostname), "repo" (same git root), "all" (default). All scopes are within the current ensemble.'),
22
+ },
23
+ async (args) => {
24
+ const scope = (args.scope ?? 'all') as 'machine' | 'repo' | 'all';
25
+ let query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${config.ensemble}"`;
26
+
27
+ if (scope === 'machine') {
28
+ query += ` AND ClaudeTempoHostname = "${os.hostname()}"`;
29
+ }
30
+
31
+ const players: Array<{
32
+ playerId: string;
33
+ part: string;
34
+ hostname: string;
35
+ workDir: string;
36
+ gitRoot?: string;
37
+ gitBranch?: string;
38
+ isConductor: boolean;
39
+ isYou: boolean;
40
+ }> = [];
41
+
42
+ try {
43
+ for await (const workflow of client.workflow.list({ query })) {
44
+ try {
45
+ const handle = client.workflow.getHandle(workflow.workflowId);
46
+ const metadata: SessionMetadata = await handle.query('getMetadata');
47
+ const part: string = await handle.query('getPart');
48
+
49
+ if (scope === 'repo') {
50
+ const ownHandle = client.workflow.getHandle(ownWorkflowId);
51
+ const ownMeta: SessionMetadata = await ownHandle.query('getMetadata');
52
+ if (metadata.gitRoot !== ownMeta.gitRoot) continue;
53
+ }
54
+
55
+ players.push({
56
+ playerId: metadata.playerId,
57
+ part,
58
+ hostname: metadata.hostname,
59
+ workDir: metadata.workDir,
60
+ gitRoot: metadata.gitRoot,
61
+ gitBranch: metadata.gitBranch,
62
+ isConductor: metadata.isConductor,
63
+ isYou: metadata.playerId === getPlayerId(),
64
+ });
65
+ } catch {
66
+ // Workflow may have just completed — skip it
67
+ }
68
+ }
69
+ } catch (err) {
70
+ return {
71
+ content: [{ type: 'text' as const, text: `Error listing workflows: ${err}` }],
72
+ isError: true,
73
+ };
74
+ }
75
+
76
+ if (players.length === 0) {
77
+ return {
78
+ content: [{ type: 'text' as const, text: 'No active sessions found.' }],
79
+ };
80
+ }
81
+
82
+ const lines = players.map((p) => {
83
+ const tags = [
84
+ p.isYou ? '(you)' : '',
85
+ p.isConductor ? '(conductor)' : '',
86
+ ].filter(Boolean).join(' ');
87
+
88
+ return [
89
+ `**${p.playerId}** ${tags}`.trim(),
90
+ ` Part: ${p.part}`,
91
+ ` Dir: ${p.workDir}`,
92
+ p.gitBranch ? ` Branch: ${p.gitBranch}` : '',
93
+ ` Host: ${p.hostname}`,
94
+ ].filter(Boolean).join('\n');
95
+ });
96
+
97
+ return {
98
+ content: [{ type: 'text' as const, text: lines.join('\n\n') }],
99
+ };
100
+ },
101
+ );
102
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+
4
+ /**
5
+ * Wrapper around McpServer.tool() that avoids TS2589 deep type instantiation
6
+ * errors caused by Zod 3.25 + MCP SDK type inference interaction.
7
+ */
8
+ export function defineTool(
9
+ server: McpServer,
10
+ name: string,
11
+ description: string,
12
+ paramsSchema: Record<string, z.ZodTypeAny>,
13
+ handler: (args: Record<string, any>, extra: any) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>,
14
+ ) {
15
+ (server.tool as Function)(name, description, paramsSchema, handler);
16
+ }
@@ -0,0 +1,43 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { WorkflowHandle } from '@temporalio/client';
3
+ import { Message } from '../types';
4
+ import { defineTool } from './helpers';
5
+
6
+ export function registerListenTool(
7
+ server: McpServer,
8
+ handle: WorkflowHandle,
9
+ ) {
10
+ defineTool(
11
+ server,
12
+ 'listen',
13
+ 'Check for pending messages from other sessions. Use this if you want to manually check for new messages.',
14
+ {},
15
+ async () => {
16
+ try {
17
+ const messages: Message[] = await handle.query('pendingMessages');
18
+
19
+ if (messages.length === 0) {
20
+ return {
21
+ content: [{ type: 'text' as const, text: 'No pending messages.' }],
22
+ };
23
+ }
24
+
25
+ // Mark messages as delivered
26
+ const ids = messages.map((m) => m.id);
27
+ await handle.signal('markDelivered', ids);
28
+
29
+ const lines = messages.map(
30
+ (m) => `**${m.from}** (${m.timestamp}):\n${m.text}`,
31
+ );
32
+ return {
33
+ content: [{ type: 'text' as const, text: lines.join('\n\n') }],
34
+ };
35
+ } catch (err) {
36
+ return {
37
+ content: [{ type: 'text' as const, text: `Failed to check messages: ${err}` }],
38
+ isError: true,
39
+ };
40
+ }
41
+ },
42
+ );
43
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Client } from '@temporalio/client';
4
+ import { Config } from '../config';
5
+ import { spawnInTerminal } from '../spawn';
6
+ import { resolveSession } from './resolve';
7
+ import { defineTool } from './helpers';
8
+
9
+ const log = (...args: unknown[]) => console.error('[claude-tempo:recruit]', ...args);
10
+
11
+ function sleep(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ export function registerRecruitTool(
16
+ server: McpServer,
17
+ client: Client,
18
+ config: Config,
19
+ getPlayerId: () => string,
20
+ ) {
21
+ defineTool(
22
+ server,
23
+ 'recruit',
24
+ 'Start a new named Claude Code session in a directory. Rejects if the name is already active.',
25
+ {
26
+ workDir: z.string().describe('The working directory for the new session'),
27
+ name: z.string().describe('Name for the new session'),
28
+ initialMessage: z.string().optional()
29
+ .describe('Optional task or message for the new session (sent after it sets its name)'),
30
+ },
31
+ async (args) => {
32
+ const { workDir, name, initialMessage } = args as {
33
+ workDir: string;
34
+ name: string;
35
+ initialMessage?: string;
36
+ };
37
+ // Validate name to prevent search attribute query injection
38
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
39
+ return {
40
+ content: [{
41
+ type: 'text' as const,
42
+ text: `Invalid name "${name}". Names must contain only letters, numbers, hyphens, and underscores.`,
43
+ }],
44
+ isError: true,
45
+ };
46
+ }
47
+
48
+ try {
49
+ // Check if a session with this name is already active
50
+ const existing = await resolveSession(client, config.ensemble, name);
51
+ if (existing) {
52
+ return {
53
+ content: [{
54
+ type: 'text' as const,
55
+ text: `Session **${name}** is already active. Use \`cue\` to send it a message, or \`terminate\` it first.`,
56
+ }],
57
+ isError: true,
58
+ };
59
+ }
60
+
61
+ // Record existing workflows so we can find the new one
62
+ const existingIds = new Set<string>();
63
+ const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${config.ensemble}"`;
64
+ for await (const wf of client.workflow.list({ query: listQuery })) {
65
+ existingIds.add(wf.workflowId);
66
+ }
67
+
68
+ // Spawn a new Claude Code session in a visible terminal
69
+ const spawnArgs = [
70
+ '--dangerously-skip-permissions',
71
+ '--dangerously-load-development-channels', 'server:claude-tempo',
72
+ '-n', name,
73
+ ];
74
+ const { pid } = spawnInTerminal(spawnArgs, workDir, {
75
+ CLAUDE_TEMPO_ENSEMBLE: config.ensemble,
76
+ CLAUDE_TEMPO_CONDUCTOR: '',
77
+ });
78
+
79
+ log(`Spawned claude process (pid ${pid}) in ${workDir} as "${name}"`);
80
+
81
+ // Poll for the new workflow to appear (up to ~15s)
82
+ let newWorkflowId: string | null = null;
83
+ for (let attempt = 0; attempt < 30; attempt++) {
84
+ await sleep(500);
85
+ for await (const wf of client.workflow.list({ query: listQuery })) {
86
+ if (!existingIds.has(wf.workflowId)) {
87
+ newWorkflowId = wf.workflowId;
88
+ break;
89
+ }
90
+ }
91
+ if (newWorkflowId) break;
92
+ }
93
+
94
+ if (!newWorkflowId) {
95
+ return {
96
+ content: [{
97
+ type: 'text' as const,
98
+ text: `Session "${name}" spawned but did not register within 15 seconds. It may still be starting up. Check \`ensemble\` shortly.`,
99
+ }],
100
+ };
101
+ }
102
+
103
+ // Send it a message instructing it to set its name
104
+ const newHandle = client.workflow.getHandle(newWorkflowId);
105
+ const nameInstruction = `You have been recruited as "${name}". Call set_name("${name}") immediately.`;
106
+ const fullMessage = initialMessage
107
+ ? `${nameInstruction}\n\nThen: ${initialMessage}`
108
+ : nameInstruction;
109
+
110
+ await newHandle.signal('receiveMessage', {
111
+ from: getPlayerId(),
112
+ text: fullMessage,
113
+ });
114
+
115
+ return {
116
+ content: [{
117
+ type: 'text' as const,
118
+ text: `Recruited session **${name}** in ${workDir}. It will set its name shortly.${initialMessage ? ' Initial task sent.' : ''}`,
119
+ }],
120
+ };
121
+ } catch (err) {
122
+ return {
123
+ content: [{ type: 'text' as const, text: `Failed to recruit: ${err}` }],
124
+ isError: true,
125
+ };
126
+ }
127
+ },
128
+ );
129
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Client } from '@temporalio/client';
4
+ import { Config, conductorWorkflowId, sessionWorkflowId } from '../config';
5
+ import { defineTool } from './helpers';
6
+
7
+ const log = (...args: unknown[]) => console.error('[claude-tempo:report]', ...args);
8
+
9
+ export function registerReportTool(
10
+ server: McpServer,
11
+ client: Client,
12
+ config: Config,
13
+ getPlayerId: () => string,
14
+ ) {
15
+ defineTool(
16
+ server,
17
+ 'report',
18
+ 'Send an update to the conductor. Use this to report task completion, blockers, or questions. No-op if no conductor is running.',
19
+ {
20
+ text: z.string().describe('The report content'),
21
+ type: z.enum(['result', 'blocker', 'question']).optional()
22
+ .describe('Type of report: "result" (default, task done), "blocker" (stuck), "question" (need input)'),
23
+ },
24
+ async (args) => {
25
+ const text = args.text as string;
26
+ const type = (args.type ?? 'result') as 'result' | 'blocker' | 'question';
27
+ try {
28
+ const conductorHandle = client.workflow.getHandle(conductorWorkflowId(config.ensemble));
29
+ await conductorHandle.signal('playerReport', {
30
+ playerId: getPlayerId(),
31
+ text,
32
+ type,
33
+ });
34
+
35
+ // Record outbound on sender's own workflow
36
+ try {
37
+ const senderHandle = client.workflow.getHandle(
38
+ sessionWorkflowId(config.ensemble, getPlayerId()),
39
+ );
40
+ await senderHandle.signal('recordSentMessage', { to: 'conductor', text: `[${type}] ${text}` });
41
+ } catch (err) {
42
+ log('Failed to record sent message:', err);
43
+ }
44
+
45
+ return {
46
+ content: [{ type: 'text' as const, text: `Report sent to conductor: [${type}] ${text}` }],
47
+ };
48
+ } catch {
49
+ return {
50
+ content: [{ type: 'text' as const, text: 'No conductor running. Report not sent (this is normal for peer-only ensembles).' }],
51
+ };
52
+ }
53
+ },
54
+ );
55
+ }
@@ -0,0 +1,39 @@
1
+ import { Client, WorkflowHandle } from '@temporalio/client';
2
+ import { SessionMetadata } from '../types';
3
+
4
+ /**
5
+ * Resolve a session by player name.
6
+ * 1. Try search attribute query (fast, indexed — but eventually consistent)
7
+ * 2. Fall back to listing all ensemble workflows and querying metadata (always current)
8
+ */
9
+ function escapeQueryString(s: string): string {
10
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
11
+ }
12
+
13
+ export async function resolveSession(
14
+ client: Client,
15
+ ensemble: string,
16
+ playerName: string,
17
+ ): Promise<WorkflowHandle | null> {
18
+ // Fast path: search attribute (may lag behind by a few seconds after rename)
19
+ const saQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${escapeQueryString(ensemble)}" AND ClaudeTempoPlayerId = "${escapeQueryString(playerName)}"`;
20
+ for await (const wf of client.workflow.list({ query: saQuery })) {
21
+ return client.workflow.getHandle(wf.workflowId);
22
+ }
23
+
24
+ // Fallback: list all ensemble workflows and check in-memory metadata
25
+ const fallbackQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${escapeQueryString(ensemble)}"`;
26
+ for await (const wf of client.workflow.list({ query: fallbackQuery })) {
27
+ try {
28
+ const handle = client.workflow.getHandle(wf.workflowId);
29
+ const metadata: SessionMetadata = await handle.query('getMetadata');
30
+ if (metadata.playerId === playerName) {
31
+ return handle;
32
+ }
33
+ } catch {
34
+ // Workflow may have just completed — skip
35
+ }
36
+ }
37
+
38
+ return null;
39
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { WorkflowHandle, Client } from '@temporalio/client';
4
+ import { Config } from '../config';
5
+ import { resolveSession } from './resolve';
6
+ import { defineTool } from './helpers';
7
+
8
+ export function registerSetNameTool(
9
+ server: McpServer,
10
+ client: Client,
11
+ config: Config,
12
+ handle: WorkflowHandle,
13
+ getPlayerId: () => string,
14
+ setPlayerId: (id: string) => void,
15
+ ) {
16
+ defineTool(
17
+ server,
18
+ 'set_name',
19
+ 'Set a human-readable name for this session. Visible to other players in the ensemble.',
20
+ {
21
+ name: z.string().describe('The name for this session (e.g., "UX", "API", "test-runner")'),
22
+ },
23
+ async (args) => {
24
+ const { name } = args as { name: string };
25
+
26
+ // Validate name to prevent search attribute query injection
27
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
28
+ return {
29
+ content: [{ type: 'text' as const, text: `Invalid name "${name}". Names must contain only letters, numbers, hyphens, and underscores.` }],
30
+ isError: true,
31
+ };
32
+ }
33
+
34
+ // Check if the name is already taken
35
+ const existing = await resolveSession(client, config.ensemble, name);
36
+ if (existing && existing.workflowId !== handle.workflowId) {
37
+ return {
38
+ content: [{ type: 'text' as const, text: `Name **${name}** is already taken by another session. Choose a different name.` }],
39
+ isError: true,
40
+ };
41
+ }
42
+
43
+ try {
44
+ await handle.signal('setName', name);
45
+ setPlayerId(name);
46
+ return {
47
+ content: [{ type: 'text' as const, text: `Session name set to **${name}**. Run \`/rename ${name}\` to match your Claude Code session name.` }],
48
+ };
49
+ } catch (err) {
50
+ return {
51
+ content: [{ type: 'text' as const, text: `Failed to set name: ${err}` }],
52
+ isError: true,
53
+ };
54
+ }
55
+ },
56
+ );
57
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { WorkflowHandle } from '@temporalio/client';
4
+ import { defineTool } from './helpers';
5
+
6
+ export function registerSetPartTool(
7
+ server: McpServer,
8
+ handle: WorkflowHandle,
9
+ ) {
10
+ defineTool(
11
+ server,
12
+ 'set_part',
13
+ 'Update your description of what you are currently working on. Visible to other sessions via ensemble.',
14
+ {
15
+ part: z.string().describe('A short description of your current work'),
16
+ },
17
+ async (args) => {
18
+ const { part } = args as { part: string };
19
+ try {
20
+ await handle.signal('setPart', part);
21
+ return {
22
+ content: [{ type: 'text' as const, text: `Part updated: "${part}"` }],
23
+ };
24
+ } catch (err) {
25
+ return {
26
+ content: [{ type: 'text' as const, text: `Failed to update part: ${err}` }],
27
+ isError: true,
28
+ };
29
+ }
30
+ },
31
+ );
32
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Client } from '@temporalio/client';
4
+ import { Config } from '../config';
5
+ import { resolveSession } from './resolve';
6
+ import { defineTool } from './helpers';
7
+
8
+ export function registerTerminateTool(
9
+ server: McpServer,
10
+ client: Client,
11
+ config: Config,
12
+ getPlayerId: () => string,
13
+ ) {
14
+ defineTool(
15
+ server,
16
+ 'terminate',
17
+ 'Terminate a player session by name. Use this to clean up orphaned sessions.',
18
+ {
19
+ playerId: z.string().describe('The player name of the session to terminate'),
20
+ },
21
+ async (args) => {
22
+ const { playerId } = args as { playerId: string };
23
+
24
+ if (playerId === getPlayerId()) {
25
+ return {
26
+ content: [{ type: 'text' as const, text: 'Cannot terminate your own session.' }],
27
+ isError: true,
28
+ };
29
+ }
30
+
31
+ try {
32
+ const handle = await resolveSession(client, config.ensemble, playerId);
33
+ if (!handle) {
34
+ return {
35
+ content: [{ type: 'text' as const, text: `No active session found with name "${playerId}".` }],
36
+ isError: true,
37
+ };
38
+ }
39
+ // Warn the session before terminating
40
+ try {
41
+ await handle.signal('receiveMessage', {
42
+ from: getPlayerId(),
43
+ text: `Your session is being terminated by player ${getPlayerId()}. Please save your work and close this terminal.`,
44
+ });
45
+ } catch {
46
+ // May fail if workflow is in a bad state — proceed with termination
47
+ }
48
+
49
+ await handle.terminate(`Terminated by player ${getPlayerId()}`);
50
+ return {
51
+ content: [{ type: 'text' as const, text: `Session **${playerId}** terminated. If the Claude Code terminal is still open, the user will need to close it manually.` }],
52
+ };
53
+ } catch (err) {
54
+ return {
55
+ content: [{ type: 'text' as const, text: `Failed to terminate: ${err}` }],
56
+ isError: true,
57
+ };
58
+ }
59
+ },
60
+ );
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,64 @@
1
+ // Shared types used by both workflow code (V8 sandbox) and Node.js server code.
2
+ // This file must NOT import from @temporalio/* — it's pure TypeScript types.
3
+
4
+ export interface SessionMetadata {
5
+ playerId: string;
6
+ ensemble: string;
7
+ hostname: string;
8
+ workDir: string;
9
+ gitRoot?: string;
10
+ gitBranch?: string;
11
+ isConductor: boolean;
12
+ }
13
+
14
+ export interface SessionInput {
15
+ metadata: SessionMetadata;
16
+ /** Restored from continue-as-new */
17
+ part?: string;
18
+ /** Restored from continue-as-new (undelivered only) */
19
+ messages?: Message[];
20
+ /** Restored from continue-as-new */
21
+ sentMessages?: SentMessage[];
22
+ /** Restored from continue-as-new (conductor only) */
23
+ commandHistory?: Command[];
24
+ /** Restored from continue-as-new (conductor only) */
25
+ reportHistory?: PlayerReport[];
26
+ autoSummary?: string;
27
+ /** Disable stale session detection (for passive mailbox workflows like maestro) */
28
+ disableStaleDetection?: boolean;
29
+ }
30
+
31
+ export interface Message {
32
+ id: string;
33
+ from: string;
34
+ text: string;
35
+ timestamp: string;
36
+ delivered: boolean;
37
+ }
38
+
39
+ export interface SentMessage {
40
+ id: string;
41
+ to: string;
42
+ text: string;
43
+ timestamp: string;
44
+ }
45
+
46
+ export interface Command {
47
+ text: string;
48
+ source: string;
49
+ replyTo?: string;
50
+ timestamp: string;
51
+ }
52
+
53
+ export interface PlayerReport {
54
+ playerId: string;
55
+ text: string;
56
+ type: 'result' | 'blocker' | 'question';
57
+ timestamp: string;
58
+ }
59
+
60
+ export interface HistoryEntry {
61
+ type: 'command' | 'report';
62
+ timestamp: string;
63
+ data: Command | PlayerReport;
64
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,34 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import { Worker, NativeConnection, bundleWorkflowCode } from '@temporalio/worker';
4
+ import { Config } from './config';
5
+
6
+ const BUNDLE_PATH = path.resolve(__dirname, '..', 'workflow-bundle.js');
7
+
8
+ async function getWorkflowBundle(): Promise<{ code: string }> {
9
+ // Use pre-built bundle if it exists, otherwise bundle from source
10
+ if (fs.existsSync(BUNDLE_PATH)) {
11
+ return { code: fs.readFileSync(BUNDLE_PATH, 'utf-8') };
12
+ }
13
+ const bundle = await bundleWorkflowCode({
14
+ workflowsPath: path.resolve(__dirname, 'workflows', 'session'),
15
+ });
16
+ // Cache for subsequent workers
17
+ fs.writeFileSync(BUNDLE_PATH, bundle.code);
18
+ return bundle;
19
+ }
20
+
21
+ export async function createWorker(config: Config): Promise<Worker> {
22
+ const connection = await NativeConnection.connect({
23
+ address: config.temporalAddress,
24
+ });
25
+
26
+ const workflowBundle = await getWorkflowBundle();
27
+
28
+ return await Worker.create({
29
+ connection,
30
+ namespace: config.temporalNamespace,
31
+ taskQueue: config.taskQueue,
32
+ workflowBundle,
33
+ });
34
+ }