@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/README.md +137 -0
- package/bin/restwalker.js +81 -0
- package/index.html +1161 -0
- package/install.sh +176 -0
- package/node/app.ts +767 -0
- package/node/db.ts +392 -0
- package/node/mcp.ts +217 -0
- package/node/package-lock.json +4552 -0
- package/node/package.json +32 -0
- package/node/runner.ts +174 -0
- package/node/scheduler.ts +221 -0
- package/node/schema.ts +46 -0
- package/node/session.ts +119 -0
- package/node/tsconfig.json +14 -0
- package/package.json +39 -0
- package/uninstall.sh +36 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "restwalker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "While you rest, it walks — idle-time Claude task runner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "app.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "tsx app.ts",
|
|
9
|
+
"dev": "tsx watch app.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fastify/static": "^8.0.0",
|
|
13
|
+
"@fastify/swagger": "^9.7.0",
|
|
14
|
+
"@fastify/swagger-ui": "^6.0.0",
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
16
|
+
"better-queue": "^3.8.12",
|
|
17
|
+
"better-queue-sqlite": "^1.0.7",
|
|
18
|
+
"better-sqlite3": "^11.0.0",
|
|
19
|
+
"chokidar": "^4.0.0",
|
|
20
|
+
"drizzle-orm": "^0.45.2",
|
|
21
|
+
"fastify": "^5.0.0",
|
|
22
|
+
"zod": "^4.4.3"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/better-queue": "^3.8.6",
|
|
26
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
27
|
+
"@types/chokidar": "^1.7.5",
|
|
28
|
+
"@types/node": "^26.0.1",
|
|
29
|
+
"tsx": "^4.22.4",
|
|
30
|
+
"typescript": "^6.0.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/node/runner.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import Queue from 'better-queue'
|
|
2
|
+
import { createRequire } from 'module'
|
|
3
|
+
import { spawn } from 'child_process'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import * as db from './db.js'
|
|
7
|
+
import * as scheduler from './scheduler.js'
|
|
8
|
+
import { findSessionJsonl, analyzeSession } from './session.js'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const SqliteStore = require('better-queue-sqlite')
|
|
12
|
+
|
|
13
|
+
const QUEUE_DB = join(homedir(), '.restwalker', 'queue.db')
|
|
14
|
+
const POLL_MS = parseInt(process.env.QUEUE_POLL_MS ?? '120000')
|
|
15
|
+
|
|
16
|
+
function resolveProvider(task: db.Task): { command: string; args: string[] } {
|
|
17
|
+
const provider = task.provider_id
|
|
18
|
+
? db.getProvider(task.provider_id)
|
|
19
|
+
: db.getDefaultProvider()
|
|
20
|
+
if (!provider) throw new Error('no agent provider configured')
|
|
21
|
+
|
|
22
|
+
const cwd = task.cwd || process.env.HOME || '.'
|
|
23
|
+
const model = task.model || 'claude-sonnet-4-6'
|
|
24
|
+
let template: string[]
|
|
25
|
+
try { template = JSON.parse(provider.args_template) } catch { template = [provider.args_template] }
|
|
26
|
+
|
|
27
|
+
const args = template.map(a =>
|
|
28
|
+
a.replace(/\{\{task\}\}/g, task.description)
|
|
29
|
+
.replace(/\{\{model\}\}/g, model)
|
|
30
|
+
.replace(/\{\{cwd\}\}/g, cwd)
|
|
31
|
+
)
|
|
32
|
+
return { command: provider.command, args }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function gateOpen(): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
const cfg = db.getSettings()
|
|
38
|
+
const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
|
|
39
|
+
const usage = await scheduler.readUsage({ cacheStaleS: staleS })
|
|
40
|
+
const result = await scheduler.canRun(usage, cfg)
|
|
41
|
+
if (!result.ok) console.log(`[queue] gate: ${result.reason}`)
|
|
42
|
+
return result.ok
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.warn('[queue] gate check error:', (e as Error).message)
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface QueuePayload {
|
|
50
|
+
id: string // better-queue uses this as the task key
|
|
51
|
+
taskId: number
|
|
52
|
+
description: string
|
|
53
|
+
cwd: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function processTask(input: QueuePayload): Promise<void> {
|
|
57
|
+
const task = db.getTask(input.taskId)
|
|
58
|
+
if (!task || task.status === 'cancelled') return
|
|
59
|
+
|
|
60
|
+
console.log(`[queue] starting task #${task.id}: ${task.description.slice(0, 80)}`)
|
|
61
|
+
db.setTaskRunning(task.id)
|
|
62
|
+
|
|
63
|
+
const startedAt = Date.now()
|
|
64
|
+
const cwd = task.cwd || process.env.HOME || '.'
|
|
65
|
+
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const { command, args } = resolveProvider(task)
|
|
68
|
+
console.log(`[queue] spawn: ${command} ${args.map(a => a.length > 60 ? a.slice(0,60)+'…' : a).join(' ')}`)
|
|
69
|
+
const proc = spawn(command, args, { cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
70
|
+
|
|
71
|
+
const stdout: string[] = []
|
|
72
|
+
const stderr: string[] = []
|
|
73
|
+
proc.stdout.on('data', (d: Buffer) => stdout.push(d.toString()))
|
|
74
|
+
proc.stderr.on('data', (d: Buffer) => stderr.push(d.toString()))
|
|
75
|
+
|
|
76
|
+
proc.on('close', (code) => {
|
|
77
|
+
const result = stdout.join('').trim() || stderr.join('').trim()
|
|
78
|
+
console.log(`[queue] task #${task.id} exited code ${code}`)
|
|
79
|
+
|
|
80
|
+
if (code !== 0) {
|
|
81
|
+
db.setTaskFailed(task.id, `exit ${code}: ${result.slice(0, 500)}`)
|
|
82
|
+
reject(new Error(`exit ${code}`))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sessionPath = findSessionJsonl(cwd, startedAt)
|
|
87
|
+
let toolCalls = 0
|
|
88
|
+
let tokensUsed = 0
|
|
89
|
+
let sessionId: string | null = null
|
|
90
|
+
|
|
91
|
+
if (sessionPath) {
|
|
92
|
+
try {
|
|
93
|
+
const analysis = analyzeSession(sessionPath)
|
|
94
|
+
toolCalls = analysis.toolCalls
|
|
95
|
+
tokensUsed = analysis.tokensUsed
|
|
96
|
+
sessionId = analysis.sessionId
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.warn('[queue] session analysis error:', (e as Error).message)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
db.setTaskDone(task.id, {
|
|
103
|
+
result: result.slice(0, 1000),
|
|
104
|
+
session_id: sessionId ?? undefined,
|
|
105
|
+
session_path: sessionPath ?? undefined,
|
|
106
|
+
tool_calls: toolCalls,
|
|
107
|
+
tokens_used: tokensUsed,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const next = db.createNextRun(task)
|
|
111
|
+
if (next) console.log(`[queue] next run of #${task.id} scheduled at ${next.next_run_at}`)
|
|
112
|
+
|
|
113
|
+
resolve()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
proc.on('error', (e) => {
|
|
117
|
+
db.setTaskFailed(task.id, e.message)
|
|
118
|
+
reject(e)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function startQueue(log: (msg: string) => void): Queue<QueuePayload, void> {
|
|
124
|
+
const queue = new Queue<QueuePayload, void>(
|
|
125
|
+
(input, cb) => {
|
|
126
|
+
processTask(input).then(() => cb(null)).catch(cb)
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
store: new SqliteStore({ path: QUEUE_DB }),
|
|
130
|
+
concurrent: 1,
|
|
131
|
+
precondition: (cb: (err: null, ready: boolean) => void) => {
|
|
132
|
+
gateOpen().then(ok => cb(null, ok)).catch(() => cb(null, false))
|
|
133
|
+
},
|
|
134
|
+
preconditionRetryTimeout: POLL_MS,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
queue.on('task_failed', (taskId: string, err: Error) => {
|
|
139
|
+
log(`[queue] task ${taskId} failed: ${err?.message ?? err}`)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
log('[queue] runner started')
|
|
143
|
+
return queue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function enqueueTask(task: db.Task): void {
|
|
147
|
+
// Lazily get the queue instance — set by app.ts after startQueue()
|
|
148
|
+
const q = getQueue()
|
|
149
|
+
if (!q) throw new Error('queue not started')
|
|
150
|
+
q.push({ id: String(task.id), taskId: task.id, description: task.description, cwd: task.cwd })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let _queue: Queue<QueuePayload, void> | null = null
|
|
154
|
+
export function setQueue(q: Queue<QueuePayload, void>) { _queue = q }
|
|
155
|
+
export function getQueue() { return _queue }
|
|
156
|
+
|
|
157
|
+
let _forceRunning = false
|
|
158
|
+
|
|
159
|
+
export async function forceRunTask(taskId: number, log: (msg: string) => void): Promise<{ ok: boolean; error?: string }> {
|
|
160
|
+
if (_forceRunning) return { ok: false, error: 'already running a forced task' }
|
|
161
|
+
const task = db.getTask(taskId)
|
|
162
|
+
if (!task) return { ok: false, error: 'task not found' }
|
|
163
|
+
if (task.status !== 'pending') return { ok: false, error: `task is ${task.status}, not pending` }
|
|
164
|
+
_forceRunning = true
|
|
165
|
+
log(`[queue] force-running task #${task.id}`)
|
|
166
|
+
try {
|
|
167
|
+
await processTask({ id: String(task.id), taskId: task.id, description: task.description, cwd: task.cwd })
|
|
168
|
+
return { ok: true }
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return { ok: false, error: (e as Error).message }
|
|
171
|
+
} finally {
|
|
172
|
+
_forceRunning = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { readFileSync, statSync, existsSync } from 'fs'
|
|
5
|
+
import type { Settings } from './db.js'
|
|
6
|
+
|
|
7
|
+
export const USAGE_CACHE: string = process.env.CLAUDE_USAGE_CACHE
|
|
8
|
+
?? join(homedir(), '.claude', '.usage_cache.json')
|
|
9
|
+
|
|
10
|
+
const USAGE_API = 'https://api.anthropic.com/api/oauth/usage'
|
|
11
|
+
const KEYCHAIN_SVC = 'Claude Code-credentials'
|
|
12
|
+
const MEM_CACHE_TTL = 300_000 // 5 min in ms
|
|
13
|
+
|
|
14
|
+
export interface UsageData {
|
|
15
|
+
five_hour_pct: number
|
|
16
|
+
weekly_pct: number
|
|
17
|
+
weekly_resets_at: string | null
|
|
18
|
+
age_s: number
|
|
19
|
+
stale: boolean
|
|
20
|
+
source: 'api' | 'file'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CanRunResult {
|
|
24
|
+
ok: boolean
|
|
25
|
+
provider: 'max' | null
|
|
26
|
+
reason: string
|
|
27
|
+
next_idle_in_s: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface KeychainOAuth {
|
|
31
|
+
accessToken?: string
|
|
32
|
+
refreshToken?: string
|
|
33
|
+
expiresAt?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ApiUsageResponse {
|
|
37
|
+
five_hour?: { utilization?: number; resets_at?: string }
|
|
38
|
+
seven_day?: { utilization?: number; resets_at?: string }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let memCache: UsageData | null = null
|
|
42
|
+
let memCacheTs = 0
|
|
43
|
+
|
|
44
|
+
// ── OAuth token ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function readKeychainToken(): string | null {
|
|
47
|
+
try {
|
|
48
|
+
const raw = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SVC, '-w'], {
|
|
49
|
+
encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'],
|
|
50
|
+
}).trim()
|
|
51
|
+
const data = JSON.parse(raw) as { claudeAiOauth?: KeychainOAuth }
|
|
52
|
+
const oauth = data.claudeAiOauth ?? {}
|
|
53
|
+
if (oauth.expiresAt && Date.now() > oauth.expiresAt) {
|
|
54
|
+
console.warn('[scheduler] OAuth token expired — waiting for Claude Code to refresh it')
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
return oauth.accessToken ?? null
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.warn('[scheduler] keychain read failed:', (e as Error).message)
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Live API fetch ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function fetchUsageFromApi(): Promise<UsageData | null> {
|
|
67
|
+
const token = readKeychainToken()
|
|
68
|
+
if (!token) return null
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const resp = await fetch(USAGE_API, {
|
|
72
|
+
headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' },
|
|
73
|
+
signal: AbortSignal.timeout(5000),
|
|
74
|
+
})
|
|
75
|
+
if (resp.status === 401) {
|
|
76
|
+
console.warn('[scheduler] API 401 — token expired, waiting for Claude Code to refresh it')
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
if (resp.status === 429) {
|
|
80
|
+
console.warn('[scheduler] API 429 — rate limited, will retry next poll')
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
if (!resp.ok) {
|
|
84
|
+
console.warn(`[scheduler] API HTTP ${resp.status}`)
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
const data = await resp.json() as ApiUsageResponse
|
|
88
|
+
const fiveH = data.five_hour ?? {}
|
|
89
|
+
const sevenD = data.seven_day ?? {}
|
|
90
|
+
if (fiveH.utilization == null || sevenD.utilization == null) return null
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
five_hour_pct: fiveH.utilization,
|
|
94
|
+
weekly_pct: sevenD.utilization,
|
|
95
|
+
weekly_resets_at: sevenD.resets_at ?? null,
|
|
96
|
+
age_s: 0,
|
|
97
|
+
stale: false,
|
|
98
|
+
source: 'api',
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.warn('[scheduler] API fetch failed:', (e as Error).message)
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── File fallback ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function readUsageFromFile(cacheStaleS = 1800): UsageData | null {
|
|
109
|
+
if (!existsSync(USAGE_CACHE)) return null
|
|
110
|
+
try {
|
|
111
|
+
const ageS = (Date.now() - statSync(USAGE_CACHE).mtimeMs) / 1000
|
|
112
|
+
const data = JSON.parse(readFileSync(USAGE_CACHE, 'utf8')) as {
|
|
113
|
+
rate_limits?: {
|
|
114
|
+
five_hour?: { used_percentage?: number }
|
|
115
|
+
seven_day?: { used_percentage?: number; resets_at?: number }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const rl = data.rate_limits ?? {}
|
|
119
|
+
const fiveHPct = rl.five_hour?.used_percentage
|
|
120
|
+
const weeklyPct = rl.seven_day?.used_percentage
|
|
121
|
+
if (fiveHPct == null || weeklyPct == null) return null
|
|
122
|
+
const resetsEpoch = rl.seven_day?.resets_at
|
|
123
|
+
return {
|
|
124
|
+
five_hour_pct: fiveHPct,
|
|
125
|
+
weekly_pct: weeklyPct,
|
|
126
|
+
weekly_resets_at: resetsEpoch ? new Date(resetsEpoch * 1000).toISOString() : null,
|
|
127
|
+
age_s: ageS,
|
|
128
|
+
stale: ageS > cacheStaleS,
|
|
129
|
+
source: 'file',
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.warn('[scheduler] file read failed:', (e as Error).message)
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── TTL cache ──────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export async function readUsage({ cacheStaleS = 1800, forceRefresh = false } = {}): Promise<UsageData | null> {
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
if (!forceRefresh && memCache && (now - memCacheTs) < MEM_CACHE_TTL) {
|
|
142
|
+
return memCache
|
|
143
|
+
}
|
|
144
|
+
const usage = await fetchUsageFromApi()
|
|
145
|
+
if (usage) {
|
|
146
|
+
memCache = usage
|
|
147
|
+
memCacheTs = now
|
|
148
|
+
return usage
|
|
149
|
+
}
|
|
150
|
+
return readUsageFromFile(cacheStaleS)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Time gate ──────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function localHour(date: Date, tz: string): number {
|
|
156
|
+
try {
|
|
157
|
+
return parseInt(
|
|
158
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hour12: false }).format(date)
|
|
159
|
+
)
|
|
160
|
+
} catch {
|
|
161
|
+
return date.getUTCHours()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isCodingWindow(now = new Date(), startH = 16, endH = 2, tz = 'America/Los_Angeles'): boolean {
|
|
166
|
+
const h = localHour(now, tz)
|
|
167
|
+
return h >= startH || h < endH
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function nextIdleInS(now = new Date(), startH = 16, endH = 2, tz = 'America/Los_Angeles'): number {
|
|
171
|
+
if (!isCodingWindow(now, startH, endH, tz)) return 0
|
|
172
|
+
const h = localHour(now, tz)
|
|
173
|
+
const m = now.getMinutes()
|
|
174
|
+
const s = now.getSeconds()
|
|
175
|
+
const remaining = h >= startH
|
|
176
|
+
? (24 - h + endH) * 3600 - m * 60 - s
|
|
177
|
+
: (endH - h) * 3600 - m * 60 - s
|
|
178
|
+
return Math.max(60, remaining)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Gate check ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function canRun(usage: UsageData | null, cfg: Settings): Promise<CanRunResult> {
|
|
184
|
+
const startH = parseInt(cfg.CODING_START_H)
|
|
185
|
+
const endH = parseInt(cfg.CODING_END_H)
|
|
186
|
+
const tz = cfg.TIMEZONE
|
|
187
|
+
const fiveHT = parseFloat(cfg.FIVE_HOUR_PAUSE_PCT)
|
|
188
|
+
const reserve = parseFloat(cfg.WEEKLY_RESERVE_PCT)
|
|
189
|
+
const hardStop = parseFloat(cfg.WEEKLY_HARD_STOP_PCT)
|
|
190
|
+
const staleS = parseFloat(cfg.CACHE_STALE_MIN) * 60
|
|
191
|
+
|
|
192
|
+
const now = new Date()
|
|
193
|
+
|
|
194
|
+
if (isCodingWindow(now, startH, endH, tz)) {
|
|
195
|
+
const idleIn = nextIdleInS(now, startH, endH, tz)
|
|
196
|
+
return {
|
|
197
|
+
ok: false, provider: null, next_idle_in_s: idleIn,
|
|
198
|
+
reason: `coding window (${startH}:00–${endH}:00 ${tz}); idle in ${Math.floor(idleIn / 60)}m`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const u = usage ?? await readUsage({ cacheStaleS: staleS })
|
|
203
|
+
|
|
204
|
+
if (u && !u.stale) {
|
|
205
|
+
const ceiling = 100 - reserve
|
|
206
|
+
if (u.five_hour_pct >= fiveHT) return {
|
|
207
|
+
ok: false, provider: null, next_idle_in_s: 0,
|
|
208
|
+
reason: `5h=${u.five_hour_pct.toFixed(0)}% ≥ ${fiveHT.toFixed(0)}% — pausing to protect coding budget`,
|
|
209
|
+
}
|
|
210
|
+
if (u.weekly_pct >= hardStop) return {
|
|
211
|
+
ok: false, provider: null, next_idle_in_s: 0,
|
|
212
|
+
reason: `weekly=${u.weekly_pct.toFixed(0)}% ≥ ${hardStop.toFixed(0)}% — hard stop until ${u.weekly_resets_at ?? '?'}`,
|
|
213
|
+
}
|
|
214
|
+
if (u.weekly_pct >= ceiling) return {
|
|
215
|
+
ok: false, provider: null, next_idle_in_s: 0,
|
|
216
|
+
reason: `weekly=${u.weekly_pct.toFixed(0)}% ≥ ${ceiling.toFixed(0)}% — reserving ${reserve.toFixed(0)}% for coding`,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: true, provider: 'max', reason: 'idle window, budget healthy', next_idle_in_s: 0 }
|
|
221
|
+
}
|
package/node/schema.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { sqliteTable, integer, text, real } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import { sql } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
const NOW = sql`(strftime('%Y-%m-%dT%H:%M:%SZ','now'))`
|
|
5
|
+
|
|
6
|
+
export const usageSnapshots = sqliteTable('usage_snapshots', {
|
|
7
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
8
|
+
five_hour_pct: real('five_hour_pct').notNull(),
|
|
9
|
+
weekly_pct: real('weekly_pct').notNull(),
|
|
10
|
+
weekly_resets_at:text('weekly_resets_at'),
|
|
11
|
+
recorded_at: text('recorded_at').notNull().default(NOW),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const settings = sqliteTable('settings', {
|
|
15
|
+
key: text('key').primaryKey(),
|
|
16
|
+
value: text('value').notNull(),
|
|
17
|
+
updated_at: text('updated_at').notNull().default(NOW),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const providers = sqliteTable('providers', {
|
|
21
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
22
|
+
name: text('name').notNull(),
|
|
23
|
+
command: text('command').notNull(),
|
|
24
|
+
args_template:text('args_template').notNull().default('[]'),
|
|
25
|
+
is_default: integer('is_default').notNull().default(0),
|
|
26
|
+
created_at: text('created_at').notNull().default(NOW),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const tasks = sqliteTable('tasks', {
|
|
30
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
31
|
+
description: text('description').notNull(),
|
|
32
|
+
cwd: text('cwd').notNull().default(''),
|
|
33
|
+
model: text('model').notNull().default('claude-sonnet-4-6'),
|
|
34
|
+
provider_id: integer('provider_id').references(() => providers.id),
|
|
35
|
+
schedule: text('schedule').notNull().default('once'),
|
|
36
|
+
next_run_at: text('next_run_at'),
|
|
37
|
+
status: text('status').notNull().default('pending'),
|
|
38
|
+
result: text('result'),
|
|
39
|
+
session_id: text('session_id'),
|
|
40
|
+
session_path:text('session_path'),
|
|
41
|
+
tool_calls: integer('tool_calls').notNull().default(0),
|
|
42
|
+
tokens_used: integer('tokens_used').notNull().default(0),
|
|
43
|
+
created_at: text('created_at').notNull().default(NOW),
|
|
44
|
+
started_at: text('started_at'),
|
|
45
|
+
finished_at: text('finished_at'),
|
|
46
|
+
})
|
package/node/session.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs'
|
|
2
|
+
import { join, basename } from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { CLAUDE_PROJECTS_DIR } from './db.js'
|
|
5
|
+
|
|
6
|
+
export interface SessionAnalysis {
|
|
7
|
+
sessionId: string
|
|
8
|
+
sessionPath: string
|
|
9
|
+
toolCalls: number
|
|
10
|
+
tokensUsed: number
|
|
11
|
+
filesWritten: string[]
|
|
12
|
+
filesEdited: string[]
|
|
13
|
+
userRequest: string
|
|
14
|
+
keySteps: string[]
|
|
15
|
+
outcome: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function findSessionJsonl(cwd: string, startedAfter: number): string | null {
|
|
19
|
+
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-')
|
|
20
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, encoded)
|
|
21
|
+
if (!existsSync(projectDir)) return null
|
|
22
|
+
|
|
23
|
+
const files = readdirSync(projectDir)
|
|
24
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
25
|
+
.map(f => ({ name: f, mtime: statSync(join(projectDir, f)).mtimeMs }))
|
|
26
|
+
.filter(f => f.mtime >= startedAfter)
|
|
27
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
28
|
+
|
|
29
|
+
return files[0] ? join(projectDir, files[0].name) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function analyzeSession(sessionPath: string): SessionAnalysis {
|
|
33
|
+
const lines = readFileSync(sessionPath, 'utf8').split('\n').filter(Boolean)
|
|
34
|
+
|
|
35
|
+
let userRequest = ''
|
|
36
|
+
let toolCalls = 0
|
|
37
|
+
let tokensUsed = 0
|
|
38
|
+
const filesWritten: string[] = []
|
|
39
|
+
const filesEdited: string[] = []
|
|
40
|
+
const keySteps: string[] = []
|
|
41
|
+
const sessionId = basename(sessionPath, '.jsonl')
|
|
42
|
+
let lastAssistantText = ''
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
let d: Record<string, unknown>
|
|
46
|
+
try { d = JSON.parse(line) } catch { continue }
|
|
47
|
+
|
|
48
|
+
const type = d.type as string
|
|
49
|
+
|
|
50
|
+
if (type === 'user' && !userRequest) {
|
|
51
|
+
const content = (d as { message?: { content?: unknown } }).message?.content
|
|
52
|
+
if (typeof content === 'string') {
|
|
53
|
+
userRequest = content.slice(0, 300)
|
|
54
|
+
} else if (Array.isArray(content)) {
|
|
55
|
+
userRequest = content
|
|
56
|
+
.filter((c): c is { type: string; text: string } =>
|
|
57
|
+
typeof c === 'object' && c !== null && (c as { type?: string }).type === 'text')
|
|
58
|
+
.map(c => c.text)
|
|
59
|
+
.join(' ')
|
|
60
|
+
.slice(0, 300)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (type === 'assistant') {
|
|
65
|
+
const msg = (d as { message?: { content?: unknown; usage?: { output_tokens?: number; input_tokens?: number } } }).message ?? {}
|
|
66
|
+
const usage = (msg as { usage?: { output_tokens?: number; input_tokens?: number } }).usage ?? {}
|
|
67
|
+
tokensUsed += (usage.output_tokens ?? 0) + (usage.input_tokens ?? 0)
|
|
68
|
+
|
|
69
|
+
const content = (msg as { content?: unknown }).content
|
|
70
|
+
if (Array.isArray(content)) {
|
|
71
|
+
for (const c of content as Array<Record<string, unknown>>) {
|
|
72
|
+
if (c.type === 'text' && typeof c.text === 'string') {
|
|
73
|
+
lastAssistantText = c.text as string
|
|
74
|
+
}
|
|
75
|
+
if (c.type === 'tool_use') {
|
|
76
|
+
toolCalls++
|
|
77
|
+
const name = c.name as string
|
|
78
|
+
const input = c.input as Record<string, unknown>
|
|
79
|
+
|
|
80
|
+
if (name === 'Write') {
|
|
81
|
+
const fp = (input.file_path as string | undefined) ?? ''
|
|
82
|
+
filesWritten.push(fp.replace(homedir(), '~'))
|
|
83
|
+
keySteps.push(`Write ${basename(fp)}`)
|
|
84
|
+
} else if (name === 'Edit') {
|
|
85
|
+
const fp = (input.file_path as string | undefined) ?? ''
|
|
86
|
+
if (!filesEdited.includes(fp)) {
|
|
87
|
+
filesEdited.push(fp.replace(homedir(), '~'))
|
|
88
|
+
keySteps.push(`Edit ${basename(fp)}`)
|
|
89
|
+
}
|
|
90
|
+
} else if (name === 'Bash') {
|
|
91
|
+
const cmd = ((input.command as string | undefined) ?? '').slice(0, 80)
|
|
92
|
+
if (cmd) keySteps.push(`Bash: ${cmd}`)
|
|
93
|
+
} else if (['WebSearch', 'WebFetch'].includes(name)) {
|
|
94
|
+
keySteps.push(name)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const seen = new Set<string>()
|
|
103
|
+
const deduped = keySteps.filter(s => {
|
|
104
|
+
const key = s.slice(0, 40)
|
|
105
|
+
if (seen.has(key)) return false
|
|
106
|
+
seen.add(key)
|
|
107
|
+
return true
|
|
108
|
+
}).slice(0, 10)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
sessionId, sessionPath,
|
|
112
|
+
toolCalls, tokensUsed,
|
|
113
|
+
filesWritten: [...new Set(filesWritten)],
|
|
114
|
+
filesEdited: [...new Set(filesEdited)],
|
|
115
|
+
userRequest: userRequest.trim(),
|
|
116
|
+
keySteps: deduped,
|
|
117
|
+
outcome: lastAssistantText.slice(0, 400).trim(),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "."
|
|
11
|
+
},
|
|
12
|
+
"include": ["*.ts"],
|
|
13
|
+
"exclude": ["node_modules", "dist"]
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentmessier/restwalker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "While you rest, it walks — idle-time Claude task runner for Mac",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"restwalker": "bin/restwalker.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"node/",
|
|
12
|
+
"index.html",
|
|
13
|
+
"install.sh",
|
|
14
|
+
"uninstall.sh",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"os": [
|
|
21
|
+
"darwin"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"claude",
|
|
25
|
+
"anthropic",
|
|
26
|
+
"mcp",
|
|
27
|
+
"task-runner",
|
|
28
|
+
"daemon",
|
|
29
|
+
"idle",
|
|
30
|
+
"background"
|
|
31
|
+
],
|
|
32
|
+
"author": "agentmessier-ai",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/agentmessier-ai/restwalker.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/agentmessier-ai/restwalker#readme"
|
|
39
|
+
}
|
package/uninstall.sh
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
PLIST_DST="$HOME/Library/LaunchAgents/com.restwalker.plist"
|
|
5
|
+
DATA_DIR="$HOME/.restwalker"
|
|
6
|
+
|
|
7
|
+
echo "==> Uninstalling restwalker..."
|
|
8
|
+
|
|
9
|
+
# ── 1. Stop and remove LaunchAgent ───────────────────────────────────────────
|
|
10
|
+
if [ -f "$PLIST_DST" ]; then
|
|
11
|
+
launchctl unload "$PLIST_DST" 2>/dev/null || true
|
|
12
|
+
rm "$PLIST_DST"
|
|
13
|
+
echo " removed LaunchAgent"
|
|
14
|
+
else
|
|
15
|
+
echo " LaunchAgent not found (already removed?)"
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# ── 2. Data directory ─────────────────────────────────────────────────────────
|
|
19
|
+
if [ -d "$DATA_DIR" ]; then
|
|
20
|
+
if [ -t 0 ]; then
|
|
21
|
+
read -r -p "Delete data directory $DATA_DIR (DB + logs)? [y/N] " confirm
|
|
22
|
+
else
|
|
23
|
+
confirm="N"
|
|
24
|
+
fi
|
|
25
|
+
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
|
26
|
+
rm -rf "$DATA_DIR"
|
|
27
|
+
echo " removed $DATA_DIR"
|
|
28
|
+
else
|
|
29
|
+
echo " kept $DATA_DIR (delete manually with: rm -rf $DATA_DIR)"
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
echo ""
|
|
34
|
+
echo "✓ restwalker uninstalled."
|
|
35
|
+
echo " The app directory was not removed — delete it manually if you want:"
|
|
36
|
+
echo " rm -rf $(cd "$(dirname "$0")" && pwd)"
|