@agentmessier/restwalker 1.0.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/node/db.ts ADDED
@@ -0,0 +1,392 @@
1
+ import Database from 'better-sqlite3'
2
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
3
+ import { eq, desc, asc, sql, and, lte, inArray } from 'drizzle-orm'
4
+ import { existsSync, mkdirSync } from 'fs'
5
+ import { homedir } from 'os'
6
+ import { join } from 'path'
7
+
8
+ import * as schema from './schema.js'
9
+
10
+ // ── Bootstrap ──────────────────────────────────────────────────────────────────
11
+
12
+ const DATA_DIR = join(homedir(), '.restwalker')
13
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true })
14
+
15
+ const DB_PATH = process.env.RESTWALKER_DB ?? join(DATA_DIR, 'restwalker.db')
16
+
17
+ export const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
18
+
19
+ const client = new Database(DB_PATH)
20
+ client.pragma('journal_mode = WAL')
21
+
22
+ const db = drizzle(client, { schema })
23
+
24
+ // ── Types ──────────────────────────────────────────────────────────────────────
25
+
26
+ export interface Settings {
27
+ CODING_START_H: string
28
+ CODING_END_H: string
29
+ TIMEZONE: string
30
+ FIVE_HOUR_PAUSE_PCT: string
31
+ WEEKLY_RESERVE_PCT: string
32
+ WEEKLY_HARD_STOP_PCT: string
33
+ POLL_INTERVAL_MIN: string
34
+ CACHE_STALE_MIN: string
35
+ [key: string]: string
36
+ }
37
+
38
+ export interface HistoryBucket {
39
+ bucket: string
40
+ five_hour_pct: number
41
+ weekly_pct: number
42
+ samples: number
43
+ }
44
+
45
+ export type Provider = typeof schema.providers.$inferSelect
46
+ export type Task = typeof schema.tasks.$inferSelect
47
+ export type TaskStatus = 'scheduled' | 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
48
+ export type TaskSchedule = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly'
49
+
50
+ export interface Snapshot {
51
+ id: number
52
+ five_hour_pct: number
53
+ weekly_pct: number
54
+ weekly_resets_at: string | null
55
+ recorded_at: string
56
+ }
57
+
58
+ export const SETTING_DEFAULTS: Settings = {
59
+ CODING_START_H: process.env.CODING_START_H ?? '16',
60
+ CODING_END_H: process.env.CODING_END_H ?? '2',
61
+ TIMEZONE: process.env.TIMEZONE ?? 'America/Los_Angeles',
62
+ FIVE_HOUR_PAUSE_PCT: process.env.FIVE_HOUR_PAUSE_PCT ?? '75',
63
+ WEEKLY_RESERVE_PCT: process.env.WEEKLY_RESERVE_PCT ?? '35',
64
+ WEEKLY_HARD_STOP_PCT: process.env.WEEKLY_HARD_STOP_PCT ?? '90',
65
+ POLL_INTERVAL_MIN: process.env.POLL_INTERVAL_MIN ?? '5',
66
+ CACHE_STALE_MIN: process.env.CACHE_STALE_MIN ?? '30',
67
+ }
68
+
69
+ // ── Schema migration ───────────────────────────────────────────────────────────
70
+
71
+ const DEFAULT_CLAUDE_ARGS = JSON.stringify([
72
+ '--print', '--permission-mode', 'auto', '--output-format', 'text',
73
+ '--model', '{{model}}', '{{task}}',
74
+ ])
75
+
76
+ export function migrate(): void {
77
+ client.exec(`
78
+ CREATE TABLE IF NOT EXISTS usage_snapshots (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ five_hour_pct REAL NOT NULL,
81
+ weekly_pct REAL NOT NULL,
82
+ weekly_resets_at TEXT,
83
+ recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
84
+ );
85
+ CREATE INDEX IF NOT EXISTS usage_snapshots_recorded_at
86
+ ON usage_snapshots(recorded_at DESC);
87
+
88
+ CREATE TABLE IF NOT EXISTS settings (
89
+ key TEXT PRIMARY KEY,
90
+ value TEXT NOT NULL,
91
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS providers (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ name TEXT NOT NULL,
97
+ command TEXT NOT NULL,
98
+ args_template TEXT NOT NULL DEFAULT '[]',
99
+ is_default INTEGER NOT NULL DEFAULT 0,
100
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS tasks (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ description TEXT NOT NULL,
106
+ cwd TEXT NOT NULL DEFAULT '',
107
+ model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6',
108
+ provider_id INTEGER REFERENCES providers(id),
109
+ schedule TEXT NOT NULL DEFAULT 'once',
110
+ next_run_at TEXT,
111
+ status TEXT NOT NULL DEFAULT 'pending',
112
+ result TEXT,
113
+ session_id TEXT,
114
+ session_path TEXT,
115
+ tool_calls INTEGER NOT NULL DEFAULT 0,
116
+ tokens_used INTEGER NOT NULL DEFAULT 0,
117
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
118
+ started_at TEXT,
119
+ finished_at TEXT
120
+ );
121
+ `)
122
+
123
+ // Seed settings defaults
124
+ db.transaction((tx) => {
125
+ for (const [key, value] of Object.entries(SETTING_DEFAULTS)) {
126
+ tx.insert(schema.settings).values({ key, value }).onConflictDoNothing().run()
127
+ }
128
+ })
129
+
130
+ // Column migrations for existing DBs
131
+ const cols = (client.prepare('PRAGMA table_info(tasks)').all() as { name: string }[]).map(c => c.name)
132
+ if (cols.length && !cols.includes('model')) client.exec("ALTER TABLE tasks ADD COLUMN model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6'")
133
+ if (cols.length && !cols.includes('provider_id')) client.exec('ALTER TABLE tasks ADD COLUMN provider_id INTEGER REFERENCES providers(id)')
134
+ if (cols.length && !cols.includes('schedule')) client.exec("ALTER TABLE tasks ADD COLUMN schedule TEXT NOT NULL DEFAULT 'once'")
135
+ if (cols.length && !cols.includes('next_run_at')) client.exec('ALTER TABLE tasks ADD COLUMN next_run_at TEXT')
136
+
137
+ // Seed default provider
138
+ const count = db.select({ n: sql<number>`count(*)` }).from(schema.providers).get()!.n
139
+ if (!count) {
140
+ db.insert(schema.providers).values({
141
+ name: 'Claude Code',
142
+ command: process.env.CLAUDE_BIN ?? 'claude',
143
+ args_template: DEFAULT_CLAUDE_ARGS,
144
+ is_default: 1,
145
+ }).run()
146
+ }
147
+
148
+ // Prune old snapshots
149
+ db.delete(schema.usageSnapshots)
150
+ .where(sql`${schema.usageSnapshots.recorded_at} < strftime('%Y-%m-%dT%H:%M:%SZ','now','-14 days')`)
151
+ .run()
152
+ }
153
+
154
+ // ── Settings repository ────────────────────────────────────────────────────────
155
+
156
+ export function getSettings(): Settings {
157
+ const rows = db.select().from(schema.settings).all()
158
+ return { ...SETTING_DEFAULTS, ...Object.fromEntries(rows.map(r => [r.key, r.value])) }
159
+ }
160
+
161
+ export function updateSettings(updates: Partial<Settings>): void {
162
+ const allowed = new Set(Object.keys(SETTING_DEFAULTS))
163
+ const unknown = Object.keys(updates).filter(k => !allowed.has(k))
164
+ if (unknown.length) throw new Error(`Unknown settings: ${unknown.join(', ')}`)
165
+
166
+ db.transaction((tx) => {
167
+ for (const [key, value] of Object.entries(updates)) {
168
+ tx.insert(schema.settings)
169
+ .values({ key, value: String(value) })
170
+ .onConflictDoUpdate({
171
+ target: schema.settings.key,
172
+ set: {
173
+ value: String(value),
174
+ updated_at: sql`(strftime('%Y-%m-%dT%H:%M:%SZ','now'))` as unknown as string,
175
+ },
176
+ })
177
+ .run()
178
+ }
179
+ })
180
+ }
181
+
182
+ // ── Snapshots repository ───────────────────────────────────────────────────────
183
+
184
+ export function recordSnapshot(fiveHourPct: number, weeklyPct: number, weeklyResetsAt: string | null): void {
185
+ db.insert(schema.usageSnapshots)
186
+ .values({ five_hour_pct: fiveHourPct, weekly_pct: weeklyPct, weekly_resets_at: weeklyResetsAt })
187
+ .run()
188
+ }
189
+
190
+ export function latestSnapshot(): Snapshot | null {
191
+ return db.select().from(schema.usageSnapshots)
192
+ .orderBy(desc(schema.usageSnapshots.recorded_at))
193
+ .limit(1)
194
+ .get() as Snapshot | null
195
+ }
196
+
197
+ export function usageHistory(hours = 48): HistoryBucket[] {
198
+ // Complex bucketing expression kept as raw SQL — no Drizzle equivalent for printf+strftime grouping
199
+ return client.prepare(`
200
+ SELECT
201
+ strftime('%Y-%m-%dT%H:', recorded_at) ||
202
+ printf('%02d', (CAST(strftime('%M', recorded_at) AS INTEGER) / 15) * 15) ||
203
+ ':00Z' AS bucket,
204
+ ROUND(AVG(five_hour_pct), 1) AS five_hour_pct,
205
+ ROUND(AVG(weekly_pct), 1) AS weekly_pct,
206
+ COUNT(*) AS samples
207
+ FROM usage_snapshots
208
+ WHERE recorded_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-' || ? || ' hours')
209
+ GROUP BY bucket
210
+ ORDER BY bucket ASC
211
+ `).all(hours) as HistoryBucket[]
212
+ }
213
+
214
+ // ── Providers repository ───────────────────────────────────────────────────────
215
+
216
+ export function getProviders(): Provider[] {
217
+ return db.select().from(schema.providers)
218
+ .orderBy(desc(schema.providers.is_default), asc(schema.providers.id))
219
+ .all()
220
+ }
221
+
222
+ export function getProvider(id: number): Provider | null {
223
+ return db.select().from(schema.providers).where(eq(schema.providers.id, id)).get() ?? null
224
+ }
225
+
226
+ export function getDefaultProvider(): Provider | null {
227
+ return db.select().from(schema.providers).where(eq(schema.providers.is_default, 1)).limit(1).get() ?? null
228
+ }
229
+
230
+ export function addProvider(name: string, command: string, argsTemplate: string): Provider {
231
+ return db.insert(schema.providers)
232
+ .values({ name, command, args_template: argsTemplate })
233
+ .returning()
234
+ .get()!
235
+ }
236
+
237
+ export function updateProvider(id: number, u: Partial<Pick<Provider, 'name' | 'command' | 'args_template'>>): void {
238
+ const set: Partial<typeof schema.providers.$inferInsert> = {}
239
+ if (u.name !== undefined) set.name = u.name
240
+ if (u.command !== undefined) set.command = u.command
241
+ if (u.args_template !== undefined) set.args_template = u.args_template
242
+ if (!Object.keys(set).length) return
243
+ db.update(schema.providers).set(set).where(eq(schema.providers.id, id)).run()
244
+ }
245
+
246
+ export function setDefaultProvider(id: number): void {
247
+ db.transaction((tx) => {
248
+ tx.update(schema.providers).set({ is_default: 0 }).run()
249
+ tx.update(schema.providers).set({ is_default: 1 }).where(eq(schema.providers.id, id)).run()
250
+ })
251
+ }
252
+
253
+ export function deleteProvider(id: number): void {
254
+ db.delete(schema.providers).where(eq(schema.providers.id, id)).run()
255
+ }
256
+
257
+ // ── Tasks repository ───────────────────────────────────────────────────────────
258
+
259
+ const DEFAULT_MODEL = 'claude-sonnet-4-6'
260
+
261
+ function computeNextRun(schedule: TaskSchedule): string {
262
+ const d = new Date()
263
+ if (schedule === 'hourly') return new Date(d.getTime() + 3_600_000).toISOString()
264
+ if (schedule === 'daily') return new Date(d.getTime() + 86_400_000).toISOString()
265
+ if (schedule === 'weekly') return new Date(d.getTime() + 7 * 86_400_000).toISOString()
266
+ if (schedule === 'monthly') { d.setMonth(d.getMonth() + 1); return d.toISOString() }
267
+ return d.toISOString()
268
+ }
269
+
270
+ export function addTask(
271
+ description: string, cwd = '', model = DEFAULT_MODEL,
272
+ providerId?: number | null, schedule: TaskSchedule = 'once',
273
+ ): Task {
274
+ return db.insert(schema.tasks).values({
275
+ description,
276
+ cwd: cwd || process.env.HOME || '',
277
+ model: model || DEFAULT_MODEL,
278
+ provider_id: providerId ?? null,
279
+ schedule,
280
+ }).returning().get()!
281
+ }
282
+
283
+ export function createNextRun(task: Task): Task | null {
284
+ if (!task.schedule || task.schedule === 'once') return null
285
+ return db.insert(schema.tasks).values({
286
+ description: task.description,
287
+ cwd: task.cwd,
288
+ model: task.model,
289
+ provider_id: task.provider_id,
290
+ schedule: task.schedule,
291
+ status: 'scheduled',
292
+ next_run_at: computeNextRun(task.schedule as TaskSchedule),
293
+ }).returning().get()!
294
+ }
295
+
296
+ export function getScheduledDueTasks(): Task[] {
297
+ return db.select().from(schema.tasks)
298
+ .where(and(
299
+ eq(schema.tasks.status, 'scheduled'),
300
+ lte(schema.tasks.next_run_at, sql`strftime('%Y-%m-%dT%H:%M:%SZ','now')`),
301
+ ))
302
+ .all()
303
+ }
304
+
305
+ export function getTasks(limit = 25, offset = 0): Task[] {
306
+ return db.select().from(schema.tasks)
307
+ .orderBy(desc(schema.tasks.id))
308
+ .limit(limit)
309
+ .offset(offset)
310
+ .all()
311
+ }
312
+
313
+ export function getTaskCount(): number {
314
+ return db.select({ n: sql<number>`count(*)` }).from(schema.tasks).get()!.n
315
+ }
316
+
317
+ export function getTask(id: number): Task | null {
318
+ return db.select().from(schema.tasks).where(eq(schema.tasks.id, id)).get() ?? null
319
+ }
320
+
321
+ export function setTaskPending(id: number): void {
322
+ db.update(schema.tasks)
323
+ .set({ status: 'pending', next_run_at: null })
324
+ .where(eq(schema.tasks.id, id))
325
+ .run()
326
+ }
327
+
328
+ export function setTaskRunning(id: number): void {
329
+ db.update(schema.tasks)
330
+ .set({ status: 'running', started_at: sql`(strftime('%Y-%m-%dT%H:%M:%SZ','now'))` as unknown as string })
331
+ .where(eq(schema.tasks.id, id))
332
+ .run()
333
+ }
334
+
335
+ export function setTaskDone(id: number, updates: {
336
+ result?: string; session_id?: string; session_path?: string
337
+ tool_calls?: number; tokens_used?: number
338
+ }): void {
339
+ const NOW_SQL = sql`(strftime('%Y-%m-%dT%H:%M:%SZ','now'))` as unknown as string
340
+ const set: Partial<typeof schema.tasks.$inferInsert> = {
341
+ status: 'done',
342
+ finished_at: NOW_SQL,
343
+ }
344
+ if (updates.result !== undefined) set.result = updates.result
345
+ if (updates.session_id !== undefined) set.session_id = updates.session_id
346
+ if (updates.session_path !== undefined) set.session_path = updates.session_path
347
+ if (updates.tool_calls !== undefined) set.tool_calls = updates.tool_calls
348
+ if (updates.tokens_used !== undefined) set.tokens_used = updates.tokens_used
349
+ db.update(schema.tasks).set(set).where(eq(schema.tasks.id, id)).run()
350
+ }
351
+
352
+ export function setTaskFailed(id: number, error: string): void {
353
+ db.update(schema.tasks)
354
+ .set({ status: 'failed', result: error, finished_at: sql`(strftime('%Y-%m-%dT%H:%M:%SZ','now'))` as unknown as string })
355
+ .where(eq(schema.tasks.id, id))
356
+ .run()
357
+ }
358
+
359
+ export function cancelTask(id: number): void {
360
+ db.update(schema.tasks)
361
+ .set({ status: 'cancelled' })
362
+ .where(and(
363
+ eq(schema.tasks.id, id),
364
+ inArray(schema.tasks.status, ['pending', 'scheduled']),
365
+ ))
366
+ .run()
367
+ }
368
+
369
+ export function deleteTask(id: number): boolean {
370
+ const result = db.delete(schema.tasks)
371
+ .where(and(
372
+ eq(schema.tasks.id, id),
373
+ inArray(schema.tasks.status, ['pending', 'scheduled', 'done', 'failed', 'cancelled']),
374
+ ))
375
+ .run()
376
+ return result.changes > 0
377
+ }
378
+
379
+ export function queueStats(): { scheduled: number; pending: number; running: number; done: number; failed: number; total: number } {
380
+ // Conditional aggregation kept as raw SQL for readability
381
+ const row = client.prepare(`
382
+ SELECT
383
+ SUM(CASE WHEN status='scheduled' THEN 1 ELSE 0 END) AS scheduled,
384
+ SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending,
385
+ SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) AS running,
386
+ SUM(CASE WHEN status='done' THEN 1 ELSE 0 END) AS done,
387
+ SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) AS failed,
388
+ COUNT(*) AS total
389
+ FROM tasks
390
+ `).get() as { scheduled: number; pending: number; running: number; done: number; failed: number; total: number }
391
+ return row
392
+ }
package/node/mcp.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Restwalker MCP server — stdio transport for Claude Code.
3
+ *
4
+ * Tool input schemas are derived at startup from the live OpenAPI spec
5
+ * at /docs/json, so they stay in sync with the API automatically.
6
+ * Descriptions are written here for Claude UX; everything else is DRY.
7
+ *
8
+ * Register with Claude Code:
9
+ * claude mcp add restwalker -- node /path/to/node_modules/.bin/tsx /path/to/node/mcp.ts
10
+ */
11
+
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
+ import { z } from 'zod'
15
+
16
+ const BASE = process.env.RESTWALKER_URL ?? 'http://localhost:47290'
17
+
18
+ // ── HTTP helper ────────────────────────────────────────────────────────────────
19
+
20
+ async function api<T = unknown>(
21
+ method: string,
22
+ path: string,
23
+ body?: unknown,
24
+ query?: Record<string, string | number>,
25
+ ): Promise<T> {
26
+ const url = new URL(`${BASE}${path}`)
27
+ if (query) {
28
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v))
29
+ }
30
+ const res = await fetch(url, {
31
+ method,
32
+ headers: body ? { 'Content-Type': 'application/json' } : {},
33
+ body: body ? JSON.stringify(body) : undefined,
34
+ })
35
+ if (!res.ok) {
36
+ const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string }
37
+ throw new Error(err.error ?? `HTTP ${res.status}`)
38
+ }
39
+ return res.json() as Promise<T>
40
+ }
41
+
42
+ function text(data: unknown) {
43
+ return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }
44
+ }
45
+
46
+ // ── Server ─────────────────────────────────────────────────────────────────────
47
+
48
+ const server = new McpServer({ name: 'restwalker', version: '1.0.0' })
49
+
50
+ // ── Status & usage ─────────────────────────────────────────────────────────────
51
+
52
+ server.tool(
53
+ 'status',
54
+ 'Get daemon status: Claude usage %, coding window, gate open/closed, thresholds',
55
+ {},
56
+ async () => text(await api('GET', '/status')),
57
+ )
58
+
59
+ server.tool(
60
+ 'can_run',
61
+ 'Quick check: is the usage gate open right now?',
62
+ {},
63
+ async () => text(await api('GET', '/can-run')),
64
+ )
65
+
66
+ server.tool(
67
+ 'usage_history',
68
+ 'Usage history bucketed into 15-minute intervals',
69
+ { hours: z.number().int().min(1).max(720).default(48).describe('How many hours back to fetch') },
70
+ async ({ hours }) => text(await api('GET', '/history', undefined, { hours: hours ?? 48 })),
71
+ )
72
+
73
+ server.tool(
74
+ 'sync',
75
+ 'Force a Claude usage cache refresh',
76
+ {},
77
+ async () => text(await api('POST', '/sync')),
78
+ )
79
+
80
+ // ── Queue ──────────────────────────────────────────────────────────────────────
81
+
82
+ server.tool(
83
+ 'queue_stats',
84
+ 'Task counts by status (scheduled / pending / running / done / failed / total)',
85
+ {},
86
+ async () => text(await api('GET', '/queue/stats')),
87
+ )
88
+
89
+ server.tool(
90
+ 'queue_list',
91
+ 'List tasks with pagination, newest first',
92
+ {
93
+ limit: z.number().int().min(1).max(100).default(25).optional().describe('Page size (max 100)'),
94
+ offset: z.number().int().min(0).default(0).optional().describe('Pagination offset'),
95
+ },
96
+ async ({ limit, offset }) =>
97
+ text(await api('GET', '/queue', undefined, { limit: limit ?? 25, offset: offset ?? 0 })),
98
+ )
99
+
100
+ server.tool(
101
+ 'queue_get',
102
+ 'Get a single task by ID',
103
+ { id: z.number().int().describe('Task ID') },
104
+ async ({ id }) => text(await api('GET', `/queue/${id}`)),
105
+ )
106
+
107
+ server.tool(
108
+ 'queue_add',
109
+ 'Enqueue a new background task for execution by Claude Code when the gate opens',
110
+ {
111
+ description: z.string().describe('The task prompt sent to the agent'),
112
+ cwd: z.string().optional().describe('Working directory for the agent'),
113
+ model: z.string().optional().describe('Model ID, e.g. claude-sonnet-4-6'),
114
+ provider_id: z.number().int().optional().describe('Provider ID (omit for default)'),
115
+ schedule: z.enum(['once','hourly','daily','weekly','monthly']).default('once').optional()
116
+ .describe('Recurrence — once runs immediately, others repeat'),
117
+ },
118
+ async (args) => text(await api('POST', '/queue', args)),
119
+ )
120
+
121
+ server.tool(
122
+ 'queue_cancel',
123
+ 'Cancel a pending or scheduled task',
124
+ { id: z.number().int().describe('Task ID') },
125
+ async ({ id }) => text(await api('DELETE', `/queue/${id}`)),
126
+ )
127
+
128
+ server.tool(
129
+ 'queue_force_run',
130
+ 'Force-run a pending task immediately, bypassing the usage gate',
131
+ { id: z.number().int().describe('Task ID') },
132
+ async ({ id }) => text(await api('POST', `/queue/${id}/force-run`)),
133
+ )
134
+
135
+ server.tool(
136
+ 'queue_session',
137
+ 'Get the parsed Claude Code session transcript for a completed task (thinking blocks, tool calls, results)',
138
+ { id: z.number().int().describe('Task ID') },
139
+ async ({ id }) => text(await api('GET', `/queue/${id}/session`)),
140
+ )
141
+
142
+ // ── Providers ──────────────────────────────────────────────────────────────────
143
+
144
+ server.tool(
145
+ 'list_providers',
146
+ 'List configured agent providers',
147
+ {},
148
+ async () => text(await api('GET', '/providers')),
149
+ )
150
+
151
+ server.tool(
152
+ 'add_provider',
153
+ 'Add a new agent provider',
154
+ {
155
+ name: z.string().describe('Display name'),
156
+ command: z.string().describe('Executable, e.g. claude or /usr/local/bin/claude'),
157
+ args_template:z.string().optional()
158
+ .describe('JSON array with {{task}}, {{model}}, {{cwd}} placeholders'),
159
+ },
160
+ async (args) => text(await api('POST', '/providers', args)),
161
+ )
162
+
163
+ server.tool(
164
+ 'set_default_provider',
165
+ 'Set the default agent provider',
166
+ { id: z.number().int().describe('Provider ID') },
167
+ async ({ id }) => text(await api('POST', `/providers/${id}/default`)),
168
+ )
169
+
170
+ // ── Discovery ──────────────────────────────────────────────────────────────────
171
+
172
+ server.tool(
173
+ 'list_models',
174
+ 'List available Anthropic models from the live API',
175
+ {},
176
+ async () => text(await api('GET', '/models')),
177
+ )
178
+
179
+ server.tool(
180
+ 'list_projects',
181
+ 'List Claude Code projects from ~/.claude/history.jsonl, sorted by recency — use as cwd suggestions',
182
+ {},
183
+ async () => text(await api('GET', '/projects')),
184
+ )
185
+
186
+ // ── Settings ───────────────────────────────────────────────────────────────────
187
+
188
+ server.tool(
189
+ 'get_settings',
190
+ 'Get all daemon settings (thresholds, timezone, poll intervals)',
191
+ {},
192
+ async () => text(await api('GET', '/settings')),
193
+ )
194
+
195
+ server.tool(
196
+ 'update_settings',
197
+ 'Update one or more daemon settings',
198
+ {
199
+ CODING_START_H: z.string().optional().describe('Hour (0-23) coding window starts'),
200
+ CODING_END_H: z.string().optional().describe('Hour (0-23) coding window ends'),
201
+ TIMEZONE: z.string().optional().describe('IANA timezone, e.g. America/Los_Angeles'),
202
+ FIVE_HOUR_PAUSE_PCT: z.string().optional().describe('5-hour usage % that pauses the gate'),
203
+ WEEKLY_RESERVE_PCT: z.string().optional().describe('Weekly usage % below which gate is always open'),
204
+ WEEKLY_HARD_STOP_PCT: z.string().optional().describe('Weekly usage % that hard-stops the gate'),
205
+ POLL_INTERVAL_MIN: z.string().optional().describe('Usage poll interval in minutes'),
206
+ CACHE_STALE_MIN: z.string().optional().describe('Cache stale threshold in minutes'),
207
+ },
208
+ async (args) => {
209
+ const updates = Object.fromEntries(Object.entries(args).filter(([, v]) => v !== undefined))
210
+ return text(await api('POST', '/settings', updates))
211
+ },
212
+ )
213
+
214
+ // ── Connect ────────────────────────────────────────────────────────────────────
215
+
216
+ const transport = new StdioServerTransport()
217
+ await server.connect(transport)