@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.
@@ -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
+ })
@@ -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)"