@miphamai/cli 0.7.9 → 0.8.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/bin/mipham.ts +38 -11
- package/package.json +1 -1
- package/src/commands/autoloop-journal.ts +107 -0
- package/src/core/engine.ts +92 -4
- package/src/shared/types.ts +8 -1
- package/src/shared/update.ts +41 -4
- package/src/tools/exec/task.ts +132 -17
- package/src/tools/index.ts +7 -0
- package/src/tools/scheduling/cron.ts +168 -0
- package/src/tools/scheduling/schedule-wakeup.ts +117 -0
- package/src/ui/commands.ts +212 -41
package/bin/mipham.ts
CHANGED
|
@@ -208,25 +208,52 @@ async function runUpdate(): Promise<boolean> {
|
|
|
208
208
|
console.log(` Checking npm registry for latest version...`)
|
|
209
209
|
console.log()
|
|
210
210
|
|
|
211
|
-
// Check latest version from npm
|
|
211
|
+
// Check latest version from npm with retry + mirror fallback
|
|
212
212
|
let latestVersion = ''
|
|
213
213
|
try {
|
|
214
214
|
const { execSync } = await import('node:child_process')
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
215
|
+
|
|
216
|
+
// Registry fallback chain: npm default → npmmirror (China mirror)
|
|
217
|
+
const attempts: Array<{ label: string; registry: string; timeout: number }> = [
|
|
218
|
+
{ label: 'npm registry', registry: 'https://registry.npmjs.org/', timeout: 10_000 },
|
|
219
|
+
{ label: 'npm registry (retry)', registry: 'https://registry.npmjs.org/', timeout: 20_000 },
|
|
220
|
+
{
|
|
221
|
+
label: 'npmmirror (China mirror)',
|
|
222
|
+
registry: 'https://registry.npmmirror.com/',
|
|
223
|
+
timeout: 15_000,
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
let lastError: Error | null = null
|
|
228
|
+
|
|
229
|
+
for (const attempt of attempts) {
|
|
230
|
+
try {
|
|
231
|
+
const result = execSync(
|
|
232
|
+
`npm view ${PACKAGE} version --json --registry=${attempt.registry}`,
|
|
233
|
+
{ encoding: 'utf-8', timeout: attempt.timeout, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
234
|
+
).trim()
|
|
235
|
+
latestVersion = result.replace(/"/g, '')
|
|
236
|
+
if (latestVersion) break
|
|
237
|
+
} catch (err) {
|
|
238
|
+
lastError = err instanceof Error ? err : new Error(String(err))
|
|
239
|
+
// Continue to next attempt
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!latestVersion && lastError) throw lastError
|
|
244
|
+
} catch (_err: unknown) {
|
|
245
|
+
console.log(`✗ Could not reach npm registry (network timeout)`)
|
|
246
|
+
console.log()
|
|
247
|
+
console.log(` Manual update:`)
|
|
248
|
+
console.log(` npm install -g ${PACKAGE}@latest`)
|
|
249
|
+
console.log()
|
|
250
|
+
console.log(` Or check: https://www.npmjs.com/package/${PACKAGE}`)
|
|
225
251
|
process.exit(1)
|
|
226
252
|
}
|
|
227
253
|
|
|
228
254
|
if (!latestVersion) {
|
|
229
255
|
console.log('✗ Could not determine latest version.')
|
|
256
|
+
console.log(` Check manually: https://www.npmjs.com/package/${PACKAGE}`)
|
|
230
257
|
process.exit(1)
|
|
231
258
|
}
|
|
232
259
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const AUTOLOOP_DIR = join(homedir(), '.mipham', 'autoloop')
|
|
6
|
+
|
|
7
|
+
function ensureDir(): void {
|
|
8
|
+
if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AutoloopJournal {
|
|
12
|
+
sessionId: string
|
|
13
|
+
prompt: string
|
|
14
|
+
status: 'active' | 'completed' | 'stopped'
|
|
15
|
+
iterations: number
|
|
16
|
+
startedAt: string
|
|
17
|
+
lastIteration?: string
|
|
18
|
+
logs: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function journalPath(sessionId: string): string {
|
|
22
|
+
return join(AUTOLOOP_DIR, `${sessionId}.json`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Create or reset an autonomous loop journal. */
|
|
26
|
+
export function createAutoloopJournal(sessionId: string, prompt: string): AutoloopJournal {
|
|
27
|
+
ensureDir()
|
|
28
|
+
const journal: AutoloopJournal = {
|
|
29
|
+
sessionId,
|
|
30
|
+
prompt,
|
|
31
|
+
status: 'active',
|
|
32
|
+
iterations: 0,
|
|
33
|
+
startedAt: new Date().toISOString(),
|
|
34
|
+
logs: [],
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
|
|
37
|
+
return journal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the journal for an autonomous loop. */
|
|
41
|
+
export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
|
|
42
|
+
const path = journalPath(sessionId)
|
|
43
|
+
if (!existsSync(path)) return null
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Log an iteration in the autonomous loop journal. */
|
|
52
|
+
export function logAutoloopIteration(sessionId: string, summary: string): void {
|
|
53
|
+
const journal = readAutoloopJournal(sessionId)
|
|
54
|
+
if (!journal) return
|
|
55
|
+
journal.iterations++
|
|
56
|
+
journal.lastIteration = new Date().toISOString()
|
|
57
|
+
journal.logs.push(`[${journal.lastIteration}] #${journal.iterations}: ${summary.slice(0, 200)}`)
|
|
58
|
+
// Keep last 50 log entries
|
|
59
|
+
if (journal.logs.length > 50) journal.logs = journal.logs.slice(-50)
|
|
60
|
+
writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Mark an autonomous loop as completed or stopped. */
|
|
64
|
+
export function completeAutoloopJournal(sessionId: string, status: 'completed' | 'stopped'): void {
|
|
65
|
+
const journal = readAutoloopJournal(sessionId)
|
|
66
|
+
if (!journal) return
|
|
67
|
+
journal.status = status
|
|
68
|
+
journal.lastIteration = new Date().toISOString()
|
|
69
|
+
writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** List all active autonomous loops. */
|
|
73
|
+
export function listActiveAutoloops(): AutoloopJournal[] {
|
|
74
|
+
ensureDir()
|
|
75
|
+
const journals: AutoloopJournal[] = []
|
|
76
|
+
for (const file of readdirSync(AUTOLOOP_DIR)) {
|
|
77
|
+
if (!file.endsWith('.json')) continue
|
|
78
|
+
try {
|
|
79
|
+
const j = JSON.parse(readFileSync(join(AUTOLOOP_DIR, file), 'utf-8'))
|
|
80
|
+
if (j.status === 'active') journals.push(j)
|
|
81
|
+
} catch {
|
|
82
|
+
/* skip */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return journals
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get the autonomous loop status message. */
|
|
89
|
+
export function getAutoloopStatus(sessionId: string): string {
|
|
90
|
+
const journal = readAutoloopJournal(sessionId)
|
|
91
|
+
if (!journal) return 'No active autonomous loop.'
|
|
92
|
+
const elapsed = Date.now() - new Date(journal.startedAt).getTime()
|
|
93
|
+
const mins = Math.floor(elapsed / 60000)
|
|
94
|
+
const lines = [
|
|
95
|
+
`── Autonomous Loop: ${journal.sessionId} ──`,
|
|
96
|
+
`Status: ${journal.status === 'active' ? '🔄 Running' : journal.status === 'completed' ? '✅ Completed' : '⏹ Stopped'}`,
|
|
97
|
+
`Prompt: "${journal.prompt.slice(0, 100)}${journal.prompt.length > 100 ? '...' : ''}"`,
|
|
98
|
+
`Iterations: ${journal.iterations}`,
|
|
99
|
+
`Started: ${journal.startedAt} (${mins}m ago)`,
|
|
100
|
+
`Last: ${journal.lastIteration || 'N/A'}`,
|
|
101
|
+
]
|
|
102
|
+
if (journal.logs.length > 0) {
|
|
103
|
+
lines.push('', 'Recent activity:')
|
|
104
|
+
for (const log of journal.logs.slice(-5)) lines.push(` ${log}`)
|
|
105
|
+
}
|
|
106
|
+
return lines.join('\n')
|
|
107
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -19,6 +19,14 @@ export class QueryEngine {
|
|
|
19
19
|
private goal?: string
|
|
20
20
|
private maxGoalLoops = 20
|
|
21
21
|
private lastAssistantContent?: string
|
|
22
|
+
/** Custom verification shell script path for goal checking. */
|
|
23
|
+
private goalVerifyScript?: string
|
|
24
|
+
/** Custom verification skill name for goal checking. */
|
|
25
|
+
private goalVerifySkill?: string
|
|
26
|
+
/** Whether to auto-decompose goal into subtasks. */
|
|
27
|
+
private goalDecompose = false
|
|
28
|
+
/** Subtask IDs created via decomposition. */
|
|
29
|
+
private goalSubtasks: string[] = []
|
|
22
30
|
|
|
23
31
|
constructor(
|
|
24
32
|
private registry: ProviderRegistry,
|
|
@@ -63,8 +71,37 @@ export class QueryEngine {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/** Set the session goal for goal-driven execution. */
|
|
66
|
-
setGoal(
|
|
74
|
+
setGoal(
|
|
75
|
+
goal: string,
|
|
76
|
+
opts?: { verifyScript?: string; verifySkill?: string; decompose?: boolean },
|
|
77
|
+
): void {
|
|
67
78
|
this.goal = goal
|
|
79
|
+
this.goalVerifyScript = opts?.verifyScript
|
|
80
|
+
this.goalVerifySkill = opts?.verifySkill
|
|
81
|
+
this.goalDecompose = opts?.decompose ?? false
|
|
82
|
+
this.goalSubtasks = []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get current goal state for status display. */
|
|
86
|
+
getGoalState(): {
|
|
87
|
+
goal?: string
|
|
88
|
+
verifyScript?: string
|
|
89
|
+
verifySkill?: string
|
|
90
|
+
decompose: boolean
|
|
91
|
+
subtasks: string[]
|
|
92
|
+
} {
|
|
93
|
+
return {
|
|
94
|
+
goal: this.goal,
|
|
95
|
+
verifyScript: this.goalVerifyScript,
|
|
96
|
+
verifySkill: this.goalVerifySkill,
|
|
97
|
+
decompose: this.goalDecompose,
|
|
98
|
+
subtasks: this.goalSubtasks,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Register subtasks created via decomposition. */
|
|
103
|
+
addGoalSubtask(taskId: string): void {
|
|
104
|
+
this.goalSubtasks.push(taskId)
|
|
68
105
|
}
|
|
69
106
|
|
|
70
107
|
/** Get the last assistant text content. */
|
|
@@ -291,12 +328,63 @@ export class QueryEngine {
|
|
|
291
328
|
|
|
292
329
|
if (!this.goal) break
|
|
293
330
|
|
|
294
|
-
//
|
|
295
|
-
const checkMsg =
|
|
331
|
+
// Build verification prompt based on mode
|
|
332
|
+
const checkMsg = this.buildGoalCheckMessage()
|
|
333
|
+
|
|
334
|
+
// If using script verification, run the script instead of asking AI
|
|
335
|
+
if (this.goalVerifyScript) {
|
|
336
|
+
const passed = await this.runScriptVerification()
|
|
337
|
+
if (passed) {
|
|
338
|
+
yield { type: 'text', content: `✅ Goal verification passed: ${this.goalVerifyScript}` }
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
// Script failed — continue looping
|
|
342
|
+
yield { type: 'text', content: `🔄 Verification script failed — continuing (loop ${loop})` }
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// If using skill verification, delegate to the skill
|
|
347
|
+
if (this.goalVerifySkill) {
|
|
348
|
+
yield { type: 'text', content: `🔍 Running verification skill: ${this.goalVerifySkill}` }
|
|
349
|
+
yield* this.process(checkMsg, signal)
|
|
350
|
+
if (this.lastAssistantContent?.includes('VERIFIED')) break
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Default: ask AI YES/NO
|
|
296
355
|
yield* this.process(checkMsg, signal)
|
|
297
|
-
// If AI responds "YES", break
|
|
298
356
|
if (this.lastAssistantContent?.includes('YES')) break
|
|
299
357
|
}
|
|
358
|
+
|
|
359
|
+
if (loop >= this.maxGoalLoops) {
|
|
360
|
+
yield {
|
|
361
|
+
type: 'text',
|
|
362
|
+
content: `⚠ Max goal loops (${this.maxGoalLoops}) reached — goal may not be achieved.`,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Build the goal verification prompt. */
|
|
368
|
+
private buildGoalCheckMessage(): string {
|
|
369
|
+
if (this.goalVerifyScript) {
|
|
370
|
+
return `Run the verification script "${this.goalVerifyScript}" to check: ${this.goal}`
|
|
371
|
+
}
|
|
372
|
+
if (this.goalVerifySkill) {
|
|
373
|
+
return `Use the skill "${this.goalVerifySkill}" to verify: ${this.goal}. If the goal is achieved, respond with VERIFIED. Otherwise explain what's missing.`
|
|
374
|
+
}
|
|
375
|
+
return `Has this goal been achieved? "${this.goal}" Answer YES or NO with reason.`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Run a shell script for goal verification. */
|
|
379
|
+
private async runScriptVerification(): Promise<boolean> {
|
|
380
|
+
if (!this.goalVerifyScript) return false
|
|
381
|
+
try {
|
|
382
|
+
const { execSync } = await import('node:child_process')
|
|
383
|
+
execSync(this.goalVerifyScript, { timeout: 30000, stdio: 'pipe' })
|
|
384
|
+
return true // exit code 0 = success
|
|
385
|
+
} catch {
|
|
386
|
+
return false // non-zero exit = not yet achieved
|
|
387
|
+
}
|
|
300
388
|
}
|
|
301
389
|
|
|
302
390
|
private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
package/src/shared/types.ts
CHANGED
|
@@ -61,7 +61,14 @@ export interface Message {
|
|
|
61
61
|
|
|
62
62
|
// ── Tool Types ──
|
|
63
63
|
export type ToolPermission = 'auto' | 'ask' | 'bypass'
|
|
64
|
-
export type ToolCategory =
|
|
64
|
+
export type ToolCategory =
|
|
65
|
+
| 'file'
|
|
66
|
+
| 'exec'
|
|
67
|
+
| 'agent'
|
|
68
|
+
| 'network'
|
|
69
|
+
| 'system'
|
|
70
|
+
| 'artifact'
|
|
71
|
+
| 'scheduling'
|
|
65
72
|
|
|
66
73
|
export interface ToolContext {
|
|
67
74
|
cwd: string
|
package/src/shared/update.ts
CHANGED
|
@@ -16,6 +16,12 @@ const HOME = homedir()
|
|
|
16
16
|
const MIPHAM_HOME = join(HOME, '.mipham')
|
|
17
17
|
const CONFIG_PATH = join(MIPHAM_HOME, 'config.yml')
|
|
18
18
|
|
|
19
|
+
// Registry fallback chain: npm default → npmmirror (China mirror)
|
|
20
|
+
const REGISTRIES = [
|
|
21
|
+
{ name: 'npm', url: 'https://registry.npmjs.org/' },
|
|
22
|
+
{ name: 'npmmirror', url: 'https://registry.npmmirror.com/' },
|
|
23
|
+
]
|
|
24
|
+
|
|
19
25
|
export interface UpdateCheck {
|
|
20
26
|
/** Current installed version */
|
|
21
27
|
current: string
|
|
@@ -43,18 +49,49 @@ export function getCurrentVersion(): string {
|
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
|
-
*
|
|
52
|
+
* Run `npm view` against a specific registry with a given timeout.
|
|
47
53
|
* Returns the version string, or throws on failure.
|
|
48
54
|
*/
|
|
49
|
-
function
|
|
50
|
-
const result = execSync(`npm view ${PACKAGE} version --json`, {
|
|
55
|
+
function tryRegistry(registry: string, timeoutMs: number): string {
|
|
56
|
+
const result = execSync(`npm view ${PACKAGE} version --json --registry=${registry}`, {
|
|
51
57
|
encoding: 'utf-8',
|
|
52
|
-
timeout:
|
|
58
|
+
timeout: timeoutMs,
|
|
53
59
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
60
|
}).trim()
|
|
55
61
|
return result.replace(/"/g, '')
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Fetch the latest version from the npm registry with retry + mirror fallback.
|
|
66
|
+
*
|
|
67
|
+
* Strategy:
|
|
68
|
+
* 1. Try npm default registry (10s timeout)
|
|
69
|
+
* 2. Retry npm default registry (20s timeout)
|
|
70
|
+
* 3. Fall back to npmmirror.com — China mirror (15s timeout)
|
|
71
|
+
*
|
|
72
|
+
* Returns the version string, or throws on all failures.
|
|
73
|
+
*/
|
|
74
|
+
function fetchLatestVersion(): string {
|
|
75
|
+
const attempts: Array<{ registry: string; timeout: number }> = [
|
|
76
|
+
{ registry: REGISTRIES[0]!.url, timeout: 10_000 },
|
|
77
|
+
{ registry: REGISTRIES[0]!.url, timeout: 20_000 },
|
|
78
|
+
{ registry: REGISTRIES[1]!.url, timeout: 15_000 },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
let lastError: Error | null = null
|
|
82
|
+
|
|
83
|
+
for (const attempt of attempts) {
|
|
84
|
+
try {
|
|
85
|
+
return tryRegistry(attempt.registry, attempt.timeout)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
lastError = err instanceof Error ? err : new Error(String(err))
|
|
88
|
+
// Continue to next attempt
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw lastError ?? new Error('Failed to fetch latest version')
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
/**
|
|
59
96
|
* Check if an update is available by comparing the local version
|
|
60
97
|
* against the npm registry.
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -4,15 +4,31 @@ interface Task {
|
|
|
4
4
|
id: string
|
|
5
5
|
subject: string
|
|
6
6
|
description: string
|
|
7
|
-
status: 'pending' | 'in_progress' | 'completed'
|
|
7
|
+
status: 'pending' | 'in_progress' | 'completed' | 'deleted'
|
|
8
|
+
activeForm?: string
|
|
9
|
+
metadata?: Record<string, unknown>
|
|
10
|
+
blocks: string[]
|
|
11
|
+
blockedBy: string[]
|
|
12
|
+
owner?: string
|
|
13
|
+
createdAt: string
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
const tasks = new Map<string, Task>()
|
|
11
17
|
let taskCounter = 0
|
|
12
18
|
|
|
19
|
+
function formatTask(t: Task): string {
|
|
20
|
+
const meta = t.metadata ? ` ${JSON.stringify(t.metadata)}` : ''
|
|
21
|
+
const blocks = t.blocks.length ? ` blocks:[${t.blocks.join(',')}]` : ''
|
|
22
|
+
const blocked = t.blockedBy.length ? ` waits:[${t.blockedBy.join(',')}]` : ''
|
|
23
|
+
return `[${t.status}] #${t.id}: ${t.subject}${blocks}${blocked}${meta}`
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
export const taskTool: ToolDefinition = {
|
|
14
27
|
name: 'Task',
|
|
15
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
'Create and manage structured task lists for tracking progress. ' +
|
|
30
|
+
'Supports CRUD, dependencies (blocks/blockedBy), metadata, owner assignment, and status workflow: pending → in_progress → completed. ' +
|
|
31
|
+
'Use for complex multi-step tasks, session tracking, and organizing work.',
|
|
16
32
|
category: 'exec',
|
|
17
33
|
permission: 'auto',
|
|
18
34
|
parameters: {
|
|
@@ -20,48 +36,147 @@ export const taskTool: ToolDefinition = {
|
|
|
20
36
|
properties: {
|
|
21
37
|
action: {
|
|
22
38
|
type: 'string',
|
|
23
|
-
enum: ['create', 'list', 'update'],
|
|
24
|
-
description:
|
|
39
|
+
enum: ['create', 'list', 'update', 'get', 'delete'],
|
|
40
|
+
description:
|
|
41
|
+
'Action: create a task, list all, update status/fields, get one by ID, or delete.',
|
|
42
|
+
},
|
|
43
|
+
subject: { type: 'string', description: 'A brief, actionable title (for create/update).' },
|
|
44
|
+
description: { type: 'string', description: 'What needs to be done (for create/update).' },
|
|
45
|
+
activeForm: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Present continuous form shown during work (for create/update).',
|
|
25
48
|
},
|
|
26
|
-
|
|
27
|
-
description: { type: 'string', description: 'Task description (for create)' },
|
|
28
|
-
taskId: { type: 'string', description: 'Task ID (for update)' },
|
|
49
|
+
taskId: { type: 'string', description: 'Task ID (for update/get/delete).' },
|
|
29
50
|
status: {
|
|
30
51
|
type: 'string',
|
|
31
|
-
enum: ['pending', 'in_progress', 'completed'],
|
|
32
|
-
description: 'New status
|
|
52
|
+
enum: ['pending', 'in_progress', 'completed', 'deleted'],
|
|
53
|
+
description: 'New status. "deleted" permanently removes the task.',
|
|
54
|
+
},
|
|
55
|
+
addBlocks: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: { type: 'string' },
|
|
58
|
+
description: 'Task IDs that this task blocks (they depend on this one).',
|
|
59
|
+
},
|
|
60
|
+
addBlockedBy: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string' },
|
|
63
|
+
description: 'Task IDs that block this task (this one depends on them).',
|
|
64
|
+
},
|
|
65
|
+
owner: { type: 'string', description: 'Agent or user name assigned to this task.' },
|
|
66
|
+
metadata: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
description: 'Arbitrary key-value metadata to attach to the task.',
|
|
33
69
|
},
|
|
34
70
|
},
|
|
35
71
|
required: ['action'],
|
|
36
72
|
},
|
|
73
|
+
|
|
37
74
|
async execute(params, _ctx) {
|
|
38
75
|
const action = params.action as string
|
|
39
76
|
|
|
77
|
+
// ── CREATE ──
|
|
40
78
|
if (action === 'create') {
|
|
41
79
|
const id = String(++taskCounter)
|
|
42
80
|
const subject = (params.subject as string) || 'Untitled'
|
|
43
|
-
|
|
81
|
+
const task: Task = {
|
|
44
82
|
id,
|
|
45
83
|
subject,
|
|
46
84
|
description: (params.description as string) || '',
|
|
47
85
|
status: 'pending',
|
|
48
|
-
|
|
86
|
+
activeForm: (params.activeForm as string) || undefined,
|
|
87
|
+
metadata: (params.metadata as Record<string, unknown>) || undefined,
|
|
88
|
+
blocks: [],
|
|
89
|
+
blockedBy: [],
|
|
90
|
+
owner: (params.owner as string) || undefined,
|
|
91
|
+
createdAt: new Date().toISOString(),
|
|
92
|
+
}
|
|
93
|
+
tasks.set(id, task)
|
|
49
94
|
return { success: true, content: `Task #${id} created: ${subject}` }
|
|
50
95
|
}
|
|
51
96
|
|
|
97
|
+
// ── LIST ──
|
|
52
98
|
if (action === 'list') {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
99
|
+
const all = Array.from(tasks.values()).filter((t) => t.status !== 'deleted')
|
|
100
|
+
if (all.length === 0) return { success: true, content: '(no tasks)' }
|
|
101
|
+
|
|
102
|
+
// Group by status
|
|
103
|
+
const groups: Record<string, Task[]> = { pending: [], in_progress: [], completed: [] }
|
|
104
|
+
for (const t of all) groups[t.status]?.push(t)
|
|
105
|
+
|
|
106
|
+
const lines: string[] = []
|
|
107
|
+
for (const status of ['in_progress', 'pending', 'completed'] as const) {
|
|
108
|
+
const group = groups[status]!
|
|
109
|
+
if (group.length === 0) continue
|
|
110
|
+
const emoji = status === 'in_progress' ? '🔄' : status === 'pending' ? '📋' : '✅'
|
|
111
|
+
lines.push(`${emoji} ${status.replace('_', ' ')} (${group.length}):`)
|
|
112
|
+
for (const t of group) lines.push(` ${formatTask(t)}`)
|
|
113
|
+
}
|
|
114
|
+
return { success: true, content: lines.join('\n') }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── GET ──
|
|
118
|
+
if (action === 'get') {
|
|
119
|
+
const taskId = params.taskId as string
|
|
120
|
+
const task = tasks.get(taskId)
|
|
121
|
+
if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
|
|
122
|
+
|
|
123
|
+
const lines = [
|
|
124
|
+
`── Task #${task.id} ──`,
|
|
125
|
+
`Subject: ${task.subject}`,
|
|
126
|
+
`Status: ${task.status}`,
|
|
127
|
+
`Description: ${task.description || '(none)'}`,
|
|
128
|
+
]
|
|
129
|
+
if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
|
|
130
|
+
if (task.owner) lines.push(`Owner: ${task.owner}`)
|
|
131
|
+
if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
|
|
132
|
+
if (task.blockedBy.length) lines.push(`Blocked by: ${task.blockedBy.join(', ')}`)
|
|
133
|
+
if (task.metadata) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`)
|
|
134
|
+
|
|
135
|
+
return { success: true, content: lines.join('\n') }
|
|
57
136
|
}
|
|
58
137
|
|
|
138
|
+
// ── UPDATE ──
|
|
59
139
|
if (action === 'update') {
|
|
60
140
|
const taskId = params.taskId as string
|
|
61
141
|
const task = tasks.get(taskId)
|
|
62
142
|
if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
|
|
63
|
-
|
|
64
|
-
|
|
143
|
+
|
|
144
|
+
if (params.subject !== undefined) task.subject = params.subject as string
|
|
145
|
+
if (params.description !== undefined) task.description = params.description as string
|
|
146
|
+
if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
|
|
147
|
+
if (params.status !== undefined) task.status = params.status as Task['status']
|
|
148
|
+
if (params.owner !== undefined) task.owner = params.owner as string
|
|
149
|
+
|
|
150
|
+
// Merge dependency arrays
|
|
151
|
+
if (Array.isArray(params.addBlocks)) {
|
|
152
|
+
for (const bid of params.addBlocks as string[]) {
|
|
153
|
+
if (!task.blocks.includes(bid)) task.blocks.push(bid)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(params.addBlockedBy)) {
|
|
157
|
+
for (const bid of params.addBlockedBy as string[]) {
|
|
158
|
+
if (!task.blockedBy.includes(bid)) task.blockedBy.push(bid)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Merge metadata
|
|
163
|
+
if (params.metadata && typeof params.metadata === 'object') {
|
|
164
|
+
task.metadata = {
|
|
165
|
+
...(task.metadata || {}),
|
|
166
|
+
...(params.metadata as Record<string, unknown>),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { success: true, content: `Task #${taskId} updated.\n${formatTask(task)}` }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── DELETE ──
|
|
174
|
+
if (action === 'delete') {
|
|
175
|
+
const taskId = params.taskId as string
|
|
176
|
+
const task = tasks.get(taskId)
|
|
177
|
+
if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
|
|
178
|
+
task.status = 'deleted'
|
|
179
|
+
return { success: true, content: `Task #${taskId} deleted.` }
|
|
65
180
|
}
|
|
66
181
|
|
|
67
182
|
return { success: false, content: '', error: `Unknown action: ${action}` }
|
package/src/tools/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { configTool } from './system/config'
|
|
|
18
18
|
import { mcpTool } from './system/mcp'
|
|
19
19
|
import { artifactTool } from './artifact/artifact'
|
|
20
20
|
import { computerUseTool } from './computer/computer-use'
|
|
21
|
+
import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
|
|
22
|
+
import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Validate tool parameters against the tool's JSON Schema definition.
|
|
@@ -124,6 +126,11 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
|
|
|
124
126
|
withValidation(artifactTool),
|
|
125
127
|
// Computer Use tools
|
|
126
128
|
withValidation(computerUseTool),
|
|
129
|
+
// Scheduling tools
|
|
130
|
+
withValidation(scheduleWakeupTool),
|
|
131
|
+
withValidation(cronCreateTool),
|
|
132
|
+
withValidation(cronDeleteTool),
|
|
133
|
+
withValidation(cronListTool),
|
|
127
134
|
]
|
|
128
135
|
|
|
129
136
|
const map = new Map<string, ToolDefinition>()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
13
|
+
|
|
14
|
+
const CRON_DIR = join(homedir(), '.mipham', 'cron')
|
|
15
|
+
|
|
16
|
+
function ensureCronDir(): void {
|
|
17
|
+
if (!existsSync(CRON_DIR)) mkdirSync(CRON_DIR, { recursive: true })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CronJob {
|
|
21
|
+
id: string
|
|
22
|
+
cron: string
|
|
23
|
+
prompt: string
|
|
24
|
+
recurring: boolean
|
|
25
|
+
createdAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jobPath(id: string): string {
|
|
29
|
+
return join(CRON_DIR, `${id}.json`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function generateId(cron: string, prompt: string): string {
|
|
33
|
+
return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readAllJobs(): CronJob[] {
|
|
37
|
+
ensureCronDir()
|
|
38
|
+
const jobs: CronJob[] = []
|
|
39
|
+
for (const file of readdirSync(CRON_DIR)) {
|
|
40
|
+
if (!file.endsWith('.json')) continue
|
|
41
|
+
try {
|
|
42
|
+
jobs.push(JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')))
|
|
43
|
+
} catch {
|
|
44
|
+
/* skip corrupt files */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const cronCreateTool: ToolDefinition = {
|
|
51
|
+
name: 'CronCreate',
|
|
52
|
+
description:
|
|
53
|
+
'Schedule a prompt to be enqueued at a future time. Uses standard 5-field cron in local timezone. ' +
|
|
54
|
+
'For recurring schedules (default): "0 9 * * *" = 9am daily. ' +
|
|
55
|
+
'For one-shot: set recurring:false with pinned minute/hour/day-of-month/month. ' +
|
|
56
|
+
'Durable — survives restarts, written to ~/.mipham/cron/.',
|
|
57
|
+
category: 'scheduling',
|
|
58
|
+
permission: 'auto',
|
|
59
|
+
parameters: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
cron: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description:
|
|
65
|
+
'Standard 5-field cron expression: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 min).',
|
|
66
|
+
},
|
|
67
|
+
prompt: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'The prompt to enqueue at each fire time.',
|
|
70
|
+
},
|
|
71
|
+
recurring: {
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
description:
|
|
74
|
+
'true (default) = fire on every cron match. false = fire once then auto-delete.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: ['cron', 'prompt'],
|
|
78
|
+
},
|
|
79
|
+
async execute(params, _ctx) {
|
|
80
|
+
const cron = params.cron as string
|
|
81
|
+
const prompt = params.prompt as string
|
|
82
|
+
const recurring = params.recurring !== false
|
|
83
|
+
const id = generateId(cron, prompt)
|
|
84
|
+
|
|
85
|
+
ensureCronDir()
|
|
86
|
+
|
|
87
|
+
const job: CronJob = {
|
|
88
|
+
id,
|
|
89
|
+
cron,
|
|
90
|
+
prompt: prompt.slice(0, 1000),
|
|
91
|
+
recurring,
|
|
92
|
+
createdAt: new Date().toISOString(),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeFileSync(jobPath(id), JSON.stringify(job, null, 2), 'utf-8')
|
|
96
|
+
|
|
97
|
+
const type = recurring ? 'recurring' : 'one-shot'
|
|
98
|
+
return {
|
|
99
|
+
success: true,
|
|
100
|
+
content:
|
|
101
|
+
`Created ${type} cron job.\n` +
|
|
102
|
+
`ID: ${id}\n` +
|
|
103
|
+
`Schedule: ${cron}\n` +
|
|
104
|
+
`Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const cronDeleteTool: ToolDefinition = {
|
|
110
|
+
name: 'CronDelete',
|
|
111
|
+
description:
|
|
112
|
+
'Cancel a cron job previously scheduled with CronCreate. Removes from ~/.mipham/cron/.',
|
|
113
|
+
category: 'scheduling',
|
|
114
|
+
permission: 'auto',
|
|
115
|
+
parameters: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
id: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: 'Job ID returned by CronCreate.',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ['id'],
|
|
124
|
+
},
|
|
125
|
+
async execute(params, _ctx) {
|
|
126
|
+
const id = params.id as string
|
|
127
|
+
const path = jobPath(id)
|
|
128
|
+
if (!existsSync(path)) {
|
|
129
|
+
return { success: false, content: '', error: `Cron job "${id}" not found.` }
|
|
130
|
+
}
|
|
131
|
+
unlinkSync(path)
|
|
132
|
+
return { success: true, content: `Cron job "${id}" deleted.` }
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const cronListTool: ToolDefinition = {
|
|
137
|
+
name: 'CronList',
|
|
138
|
+
description: 'List all cron jobs scheduled via CronCreate, both durable and session-only.',
|
|
139
|
+
category: 'scheduling',
|
|
140
|
+
permission: 'auto',
|
|
141
|
+
parameters: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {},
|
|
144
|
+
required: [],
|
|
145
|
+
},
|
|
146
|
+
async execute(_params, _ctx) {
|
|
147
|
+
const jobs = readAllJobs()
|
|
148
|
+
if (jobs.length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
content:
|
|
152
|
+
'No scheduled cron jobs.\n\n' +
|
|
153
|
+
'Use CronCreate to schedule a recurring or one-shot job.\n' +
|
|
154
|
+
'Use /loop <interval> <prompt> to start a recurring task.',
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
|
|
159
|
+
for (const j of jobs) {
|
|
160
|
+
const type = j.recurring ? 'recurring' : 'one-shot'
|
|
161
|
+
lines.push(`${j.id} ${j.cron} ${type}`)
|
|
162
|
+
lines.push(` ${j.prompt.slice(0, 100)}`)
|
|
163
|
+
lines.push('')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { success: true, content: lines.join('\n') }
|
|
167
|
+
},
|
|
168
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Active wakeup timers — in-memory, per-session.
|
|
5
|
+
* Key: "sessionId:wakeup"
|
|
6
|
+
*/
|
|
7
|
+
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
8
|
+
|
|
9
|
+
export const scheduleWakeupTool: ToolDefinition = {
|
|
10
|
+
name: 'ScheduleWakeup',
|
|
11
|
+
description:
|
|
12
|
+
'Schedule when to resume work in /loop dynamic mode — the user invoked /loop without an interval, asking you to self-pace iterations of a specific task. Do NOT schedule a short-interval wakeup to poll for background work you started — when harness-tracked work finishes, you are re-invoked automatically. The runtime clamps to [60, 3600].',
|
|
13
|
+
category: 'scheduling',
|
|
14
|
+
permission: 'auto',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
delaySeconds: {
|
|
19
|
+
type: 'number',
|
|
20
|
+
description: 'Seconds from now to wake up. Clamped to [60, 3600] by the runtime.',
|
|
21
|
+
},
|
|
22
|
+
reason: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'One short sentence explaining the chosen delay.',
|
|
25
|
+
},
|
|
26
|
+
prompt: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'The /loop input to fire on wake-up.',
|
|
29
|
+
},
|
|
30
|
+
stop: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Set to true to end the dynamic loop immediately.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: [],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async execute(params, ctx) {
|
|
39
|
+
const sessionId = ctx.sessionId
|
|
40
|
+
|
|
41
|
+
// ── Stop — cancel all timers for this session ──
|
|
42
|
+
if (params.stop === true) {
|
|
43
|
+
let cancelled = 0
|
|
44
|
+
for (const [key, timer] of activeTimers) {
|
|
45
|
+
if (key.startsWith(sessionId + ':')) {
|
|
46
|
+
clearTimeout(timer)
|
|
47
|
+
activeTimers.delete(key)
|
|
48
|
+
cancelled++
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
success: true,
|
|
53
|
+
content: `Loop ended. ${cancelled} pending wakeup(s) cancelled.`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Schedule — validate and register timer ──
|
|
58
|
+
const delaySeconds = params.delaySeconds as number
|
|
59
|
+
const prompt = (params.prompt as string) || ''
|
|
60
|
+
const reason = (params.reason as string) || 'scheduled wakeup'
|
|
61
|
+
|
|
62
|
+
if (!delaySeconds || typeof delaySeconds !== 'number') {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
content: '',
|
|
66
|
+
error: 'delaySeconds is required and must be a number in [60, 3600].',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (delaySeconds < 60 || delaySeconds > 3600) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
content: '',
|
|
74
|
+
error: `delaySeconds must be in [60, 3600], got ${delaySeconds}.`,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cancel any previous timer for this session (one active wakeup per session)
|
|
79
|
+
for (const [key, timer] of activeTimers) {
|
|
80
|
+
if (key.startsWith(sessionId + ':')) {
|
|
81
|
+
clearTimeout(timer)
|
|
82
|
+
activeTimers.delete(key)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const timerKey = `${sessionId}:wakeup`
|
|
87
|
+
const timeoutId = setTimeout(() => {
|
|
88
|
+
activeTimers.delete(timerKey)
|
|
89
|
+
}, delaySeconds * 1000)
|
|
90
|
+
|
|
91
|
+
activeTimers.set(timerKey, timeoutId)
|
|
92
|
+
|
|
93
|
+
const mins = Math.floor(delaySeconds / 60)
|
|
94
|
+
const secs = delaySeconds % 60
|
|
95
|
+
const humanDelay = mins > 0 ? `${mins}m${secs > 0 ? `${secs}s` : ''}` : `${secs}s`
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
content:
|
|
100
|
+
`⏰ Wakeup scheduled in ${humanDelay} (${reason})\n` +
|
|
101
|
+
`Prompt: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Cancel all timers for a session. Called on session cleanup. */
|
|
107
|
+
export function cancelAllSessionTimers(sessionId: string): number {
|
|
108
|
+
let count = 0
|
|
109
|
+
for (const [key, timer] of activeTimers) {
|
|
110
|
+
if (key.startsWith(sessionId + ':')) {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
activeTimers.delete(key)
|
|
113
|
+
count++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return count
|
|
117
|
+
}
|
package/src/ui/commands.ts
CHANGED
|
@@ -74,7 +74,7 @@ const helpCmd: CommandHandler = (ctx) => {
|
|
|
74
74
|
/cost Token usage estimate
|
|
75
75
|
/usage Detailed usage dashboard
|
|
76
76
|
/rename <name> Rename current session
|
|
77
|
-
/goal <text> Set session goal
|
|
77
|
+
/goal <text> [--decompose] [--verify-script <path>] [--verify-skill <name>] Set session goal with verification
|
|
78
78
|
/recap Summarize session so far
|
|
79
79
|
/summary Generate session summary
|
|
80
80
|
/stats Session usage statistics
|
|
@@ -129,8 +129,11 @@ const helpCmd: CommandHandler = (ctx) => {
|
|
|
129
129
|
/pr-comments PR review summary
|
|
130
130
|
/diff Show git diff
|
|
131
131
|
/workflows List workflow scripts
|
|
132
|
-
/loop <int> <p>
|
|
133
|
-
/loop
|
|
132
|
+
/loop <int> <p> Run prompt on interval
|
|
133
|
+
/loop auto <p> Autonomous self-paced loop
|
|
134
|
+
/loop stop Stop active autonomous loop
|
|
135
|
+
/loop init Scaffold .mipham/ vault structure
|
|
136
|
+
/loop status Show autonomous loop progress
|
|
134
137
|
/batch Apply changes across files
|
|
135
138
|
/hooks Manage lifecycle hook scripts
|
|
136
139
|
/schedule View scheduled tasks
|
|
@@ -570,17 +573,84 @@ const renameCmd: CommandHandler = (ctx, args) => {
|
|
|
570
573
|
}
|
|
571
574
|
|
|
572
575
|
const goalCmd: CommandHandler = (ctx, args) => {
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
576
|
+
const input = args.join(' ')
|
|
577
|
+
|
|
578
|
+
// ── Show goal status ──
|
|
579
|
+
if (!input.trim()) {
|
|
580
|
+
const state = ctx.engine.getGoalState?.() || { goal: undefined, decompose: false, subtasks: [] }
|
|
581
|
+
if (!state.goal) {
|
|
582
|
+
return {
|
|
583
|
+
content:
|
|
584
|
+
`Usage: /goal <statement> [options]\n\n` +
|
|
585
|
+
`Set a session-level completion condition with optional verification.\n\n` +
|
|
586
|
+
`Options:\n` +
|
|
587
|
+
` --decompose Auto-decompose goal into subtasks\n` +
|
|
588
|
+
` --verify-script <path> Run a shell script to check completion\n` +
|
|
589
|
+
` --verify-skill <name> Use a skill for verification\n\n` +
|
|
590
|
+
`Examples:\n` +
|
|
591
|
+
` /goal Fix all TS errors and make tests pass --decompose\n` +
|
|
592
|
+
` /goal Complete the build --verify-script ./check-build.sh\n` +
|
|
593
|
+
` /goal Security audit passes --verify-skill security-review`,
|
|
594
|
+
}
|
|
577
595
|
}
|
|
596
|
+
const lines = [`── Goal Status ──`, '', `🎯 Goal: ${state.goal}`]
|
|
597
|
+
if (state.verifyScript) lines.push(`📜 Verification: ${state.verifyScript}`)
|
|
598
|
+
if (state.verifySkill) lines.push(`🛠 Verification skill: ${state.verifySkill}`)
|
|
599
|
+
if (state.decompose) lines.push(`📋 Decompose: enabled (${state.subtasks.length} subtasks)`)
|
|
600
|
+
lines.push('', 'Type /goal without arguments to clear.')
|
|
601
|
+
return { content: lines.join('\n') }
|
|
578
602
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
603
|
+
|
|
604
|
+
// ── Parse options ──
|
|
605
|
+
let goal = input
|
|
606
|
+
let decompose = false
|
|
607
|
+
let verifyScript: string | undefined
|
|
608
|
+
let verifySkill: string | undefined
|
|
609
|
+
|
|
610
|
+
// Extract --decompose
|
|
611
|
+
if (goal.includes(' --decompose')) {
|
|
612
|
+
decompose = true
|
|
613
|
+
goal = goal.replace(' --decompose', '')
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Extract --verify-script <path>
|
|
617
|
+
const scriptMatch = goal.match(/ --verify-script\s+(\S+)/)
|
|
618
|
+
if (scriptMatch) {
|
|
619
|
+
verifyScript = scriptMatch[1]!
|
|
620
|
+
goal = goal.replace(` --verify-script ${verifyScript}`, '')
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Extract --verify-skill <name>
|
|
624
|
+
const skillMatch = goal.match(/ --verify-skill\s+(\S+)/)
|
|
625
|
+
if (skillMatch) {
|
|
626
|
+
verifySkill = skillMatch[1]!
|
|
627
|
+
goal = goal.replace(` --verify-skill ${verifySkill}`, '')
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
goal = goal.trim()
|
|
631
|
+
if (!goal) {
|
|
632
|
+
return { content: 'Goal text is required. Use /goal without arguments to see usage.' }
|
|
583
633
|
}
|
|
634
|
+
|
|
635
|
+
ctx.setGoal(goal)
|
|
636
|
+
ctx.engine.setGoal(goal, { verifyScript, verifySkill, decompose })
|
|
637
|
+
|
|
638
|
+
const lines = [`✓ Goal set: "${goal}"`]
|
|
639
|
+
if (decompose) {
|
|
640
|
+
lines.push('📋 Decomposition: enabled — AI will break goal into subtasks')
|
|
641
|
+
// Decompose by creating initial subtasks
|
|
642
|
+
const decomposeMsg = `Break down this goal into 3-5 subtasks: "${goal}". For each subtask, use TaskCreate with the subject and description. Mark each as blocked by the previous one to create a dependency chain.`
|
|
643
|
+
return {
|
|
644
|
+
content: lines.join('\n'),
|
|
645
|
+
forwardToAI: decomposeMsg,
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (verifyScript) lines.push(`📜 Verification: ${verifyScript}`)
|
|
649
|
+
if (verifySkill) lines.push(`🛠 Verification skill: ${verifySkill}`)
|
|
650
|
+
lines.push('')
|
|
651
|
+
lines.push('Use /status to view progress. Type /goal without arguments to clear.')
|
|
652
|
+
|
|
653
|
+
return { content: lines.join('\n') }
|
|
584
654
|
}
|
|
585
655
|
|
|
586
656
|
const recapCmd: CommandHandler = (ctx) => {
|
|
@@ -1208,25 +1278,94 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
|
|
|
1208
1278
|
return { content: lines.join('\n') }
|
|
1209
1279
|
}
|
|
1210
1280
|
|
|
1211
|
-
// ── /loop
|
|
1281
|
+
// ── /loop stop — stop autonomous loop ──
|
|
1282
|
+
if (sub === 'stop') {
|
|
1283
|
+
const { completeAutoloopJournal, listActiveAutoloops } =
|
|
1284
|
+
await import('../commands/autoloop-journal.js')
|
|
1285
|
+
const active = listActiveAutoloops()
|
|
1286
|
+
if (active.length === 0) {
|
|
1287
|
+
return { content: 'No active autonomous loops to stop.' }
|
|
1288
|
+
}
|
|
1289
|
+
for (const j of active) completeAutoloopJournal(j.sessionId, 'stopped')
|
|
1290
|
+
return { content: `Stopped ${active.length} autonomous loop(s).` }
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── /loop status — show autonomous loop progress ──
|
|
1294
|
+
if (sub === 'status') {
|
|
1295
|
+
const { listActiveAutoloops, getAutoloopStatus } =
|
|
1296
|
+
await import('../commands/autoloop-journal.js')
|
|
1297
|
+
const active = listActiveAutoloops()
|
|
1298
|
+
if (active.length === 0) {
|
|
1299
|
+
return { content: 'No active autonomous loops.\n\nUse /loop auto <prompt> to start one.' }
|
|
1300
|
+
}
|
|
1301
|
+
const lines = active.map((j) => getAutoloopStatus(j.sessionId))
|
|
1302
|
+
return { content: lines.join('\n\n') }
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ── /loop auto <prompt> — autonomous self-paced loop ──
|
|
1306
|
+
if (sub === 'auto') {
|
|
1307
|
+
const prompt = args.slice(1).join(' ')
|
|
1308
|
+
if (!prompt.trim()) {
|
|
1309
|
+
return {
|
|
1310
|
+
content:
|
|
1311
|
+
'Usage: /loop auto <prompt>\n\n' +
|
|
1312
|
+
'Start an autonomous self-paced loop. The AI will:\n' +
|
|
1313
|
+
'1. Work on the task\n' +
|
|
1314
|
+
'2. Decide how long to wait before the next iteration\n' +
|
|
1315
|
+
'3. Use ScheduleWakeup to self-schedule\n' +
|
|
1316
|
+
'4. Use ScheduleWakeup(stop:true) when the goal is reached\n\n' +
|
|
1317
|
+
'Use /loop status to view progress.\n' +
|
|
1318
|
+
'Use /loop stop to stop the loop.',
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const sessionId = `autoloop-${Date.now()}`
|
|
1323
|
+
const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
|
|
1324
|
+
createAutoloopJournal(sessionId, prompt)
|
|
1325
|
+
|
|
1326
|
+
const autoPrompt =
|
|
1327
|
+
`## Autonomous Loop — ${sessionId}\n\n` +
|
|
1328
|
+
`Task: ${prompt}\n\n` +
|
|
1329
|
+
`You are in an autonomous loop. Each iteration:\n` +
|
|
1330
|
+
`1. Make progress on the task above\n` +
|
|
1331
|
+
`2. When you need to wait (e.g., for a deploy, CI, external event), call ScheduleWakeup with:\n` +
|
|
1332
|
+
` - delaySeconds: your best estimate of how long to wait (60-3600)\n` +
|
|
1333
|
+
` - reason: one sentence explaining why\n` +
|
|
1334
|
+
` - prompt: include this full autonomous loop ID: ${sessionId}\n` +
|
|
1335
|
+
`3. When the task is COMPLETE, call ScheduleWakeup with stop:true\n\n` +
|
|
1336
|
+
`After each iteration, log progress by reading/writing the journal at ~/.mipham/autoloop/${sessionId}.json.\n` +
|
|
1337
|
+
`Use the autoloop-journal module: logAutoloopIteration("${sessionId}", "<summary>").`
|
|
1338
|
+
|
|
1339
|
+
return {
|
|
1340
|
+
content:
|
|
1341
|
+
`── Autonomous Loop Started ──\n\n` +
|
|
1342
|
+
`ID: ${sessionId}\n` +
|
|
1343
|
+
`Prompt: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"\n\n` +
|
|
1344
|
+
`The AI will self-pace. Use /loop status to view progress.\n` +
|
|
1345
|
+
`Use /loop stop to stop.`,
|
|
1346
|
+
forwardToAI: autoPrompt,
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ── /loop <interval> <prompt> — recurring prompt (or auto-detect autonomous) ──
|
|
1212
1351
|
if (args.length < 2) {
|
|
1213
1352
|
return {
|
|
1214
1353
|
content: stripIndent`
|
|
1215
|
-
Usage: /loop <interval> <prompt>
|
|
1216
|
-
or: /loop
|
|
1354
|
+
Usage: /loop <interval> <prompt> Fixed-interval loop
|
|
1355
|
+
or: /loop auto <prompt> Autonomous self-paced loop
|
|
1356
|
+
or: /loop stop Stop active autonomous loop
|
|
1357
|
+
or: /loop status Show autonomous loop progress
|
|
1358
|
+
or: /loop init [path] Scaffold project vault structure
|
|
1217
1359
|
|
|
1218
|
-
|
|
1219
|
-
a LoopKit Vault project structure with Mipham Code config.
|
|
1220
|
-
|
|
1221
|
-
Interval formats:
|
|
1360
|
+
Fixed interval formats:
|
|
1222
1361
|
10s — 10 seconds
|
|
1223
1362
|
5m — 5 minutes
|
|
1224
1363
|
1h — 1 hour
|
|
1225
1364
|
|
|
1226
|
-
|
|
1365
|
+
Examples:
|
|
1227
1366
|
/loop 5m check the deploy status
|
|
1228
|
-
/loop
|
|
1229
|
-
/loop init
|
|
1367
|
+
/loop auto monitor CI and fix failing tests
|
|
1368
|
+
/loop init Create vault in current directory
|
|
1230
1369
|
`,
|
|
1231
1370
|
}
|
|
1232
1371
|
}
|
|
@@ -1235,34 +1374,66 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
|
|
|
1235
1374
|
const prompt = args.slice(1).join(' ')
|
|
1236
1375
|
const seconds = parseInterval(interval)
|
|
1237
1376
|
|
|
1377
|
+
// Auto-detect: if first arg is not a valid interval, assume autonomous mode
|
|
1238
1378
|
if (seconds === null) {
|
|
1379
|
+
// Could be: /loop <full prompt without interval>
|
|
1380
|
+
const fullPrompt = args.join(' ')
|
|
1381
|
+
if (fullPrompt.length > 0) {
|
|
1382
|
+
const sessionId = `autoloop-${Date.now()}`
|
|
1383
|
+
const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
|
|
1384
|
+
createAutoloopJournal(sessionId, fullPrompt)
|
|
1385
|
+
|
|
1386
|
+
const autoPrompt =
|
|
1387
|
+
`## Autonomous Loop — ${sessionId}\n\n` +
|
|
1388
|
+
`Task: ${fullPrompt}\n\n` +
|
|
1389
|
+
`You are in an autonomous loop (auto-detected — no interval specified). Each iteration:\n` +
|
|
1390
|
+
`1. Make progress on the task above\n` +
|
|
1391
|
+
`2. When you need to wait, call ScheduleWakeup with your chosen delaySeconds and this loop ID: ${sessionId}\n` +
|
|
1392
|
+
`3. When COMPLETE, call ScheduleWakeup with stop:true\n\n` +
|
|
1393
|
+
`Log progress using logAutoloopIteration("${sessionId}", "<summary>").`
|
|
1394
|
+
|
|
1395
|
+
return {
|
|
1396
|
+
content:
|
|
1397
|
+
`── Autonomous Loop Started (auto-detected) ──\n\n` +
|
|
1398
|
+
`ID: ${sessionId}\n` +
|
|
1399
|
+
`Prompt: "${fullPrompt.slice(0, 100)}${fullPrompt.length > 100 ? '...' : ''}"\n\n` +
|
|
1400
|
+
`No interval specified — using autonomous self-paced mode.\n` +
|
|
1401
|
+
`Use /loop stop to stop.`,
|
|
1402
|
+
forwardToAI: autoPrompt,
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1239
1405
|
return {
|
|
1240
|
-
content: `── Invalid Interval ──\n\n"${interval}" is not a recognised interval.\n\nUse formats like: 10s, 5m, 1h, 30min, 2hr
|
|
1406
|
+
content: `── Invalid Interval ──\n\n"${interval}" is not a recognised interval.\n\nUse formats like: 10s, 5m, 1h, 30min, 2hr\nOr use /loop auto <prompt> for autonomous mode.`,
|
|
1241
1407
|
}
|
|
1242
1408
|
}
|
|
1243
1409
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1410
|
+
// Fixed-interval loop — directly invoke ScheduleWakeup
|
|
1411
|
+
try {
|
|
1412
|
+
const { scheduleWakeupTool } = await import('../tools/scheduling/schedule-wakeup.js')
|
|
1413
|
+
const result = await scheduleWakeupTool.execute(
|
|
1414
|
+
{ delaySeconds: seconds, reason: `Loop: ${prompt.slice(0, 40)}`, prompt },
|
|
1415
|
+
{ cwd: process.cwd(), sessionId: 'loop-session', provider: '', model: '' },
|
|
1416
|
+
)
|
|
1417
|
+
return result.success
|
|
1418
|
+
? { content: result.content }
|
|
1419
|
+
: { content: `Loop failed: ${result.error || 'unknown error'}` }
|
|
1420
|
+
} catch (e) {
|
|
1421
|
+
return { content: `Loop failed: ${e instanceof Error ? e.message : String(e)}` }
|
|
1247
1422
|
}
|
|
1248
1423
|
}
|
|
1249
1424
|
|
|
1250
|
-
const scheduleCmd: CommandHandler = (_ctx) =>
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
`,
|
|
1263
|
-
forwardToAI:
|
|
1264
|
-
'Use CronList to show all currently scheduled cron jobs (both durable and session-only). Present them in a clear table with: name/ID, schedule (cron expression), type (recurring vs one-shot), and status. Also note any active ScheduleWakeup loops if visible in the current session. If no scheduled tasks exist, suggest: "No scheduled tasks. Use /loop <interval> <prompt> to create a recurring task."',
|
|
1265
|
-
})
|
|
1425
|
+
const scheduleCmd: CommandHandler = async (_ctx) => {
|
|
1426
|
+
try {
|
|
1427
|
+
const { cronListTool } = await import('../tools/scheduling/cron.js')
|
|
1428
|
+
const result = await cronListTool.execute(
|
|
1429
|
+
{},
|
|
1430
|
+
{ cwd: process.cwd(), sessionId: 'schedule-view', provider: '', model: '' },
|
|
1431
|
+
)
|
|
1432
|
+
return { content: result.content }
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
return { content: `Schedule check failed: ${e instanceof Error ? e.message : String(e)}` }
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1266
1437
|
|
|
1267
1438
|
// ═══════════════════════════════════════════════════════════════
|
|
1268
1439
|
// Diagnostic
|