@miphamai/cli 0.61.0 → 0.62.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,11 +1,14 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
4
+ import { isRecoverableToolFailure } from './recoverable-failure.js'
4
5
 
5
6
  export interface RuleEffectiveness {
6
7
  ruleId: string
7
8
  appliedCount: number
8
9
  successAfterCount: number
10
+ /** 可恢复/环境性失败数——记录但排除在成功率分母外。 */
11
+ recoverableCount?: number
9
12
  preRuleFailureRate: number
10
13
  postRuleFailureRate: number
11
14
  status: 'active' | 'degrading' | 'disabled'
@@ -44,7 +47,11 @@ export class EffectivenessTracker {
44
47
  if (eff) eff.decisionId = decisionId
45
48
  }
46
49
 
47
- recordApplication(ruleId: string, success: boolean): void {
50
+ recordApplication(
51
+ ruleId: string,
52
+ success: boolean,
53
+ opts?: { toolName?: string; error?: string },
54
+ ): void {
48
55
  let eff = this.data.get(ruleId)
49
56
  if (!eff) {
50
57
  eff = {
@@ -61,6 +68,12 @@ export class EffectivenessTracker {
61
68
  this.data.set(ruleId, eff)
62
69
  }
63
70
 
71
+ // 可恢复/环境性失败:仍记录,但不进成功率分母(防误降级/误禁用能用的规则)。
72
+ if (!success && opts?.error && isRecoverableToolFailure(opts.toolName ?? '', opts.error)) {
73
+ eff.recoverableCount = (eff.recoverableCount ?? 0) + 1
74
+ return
75
+ }
76
+
64
77
  eff.appliedCount++
65
78
  if (success) eff.successAfterCount++
66
79
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 判「可恢复/环境性」失败 vs 真缺陷(borrow opencrabs `is_recoverable_tool_failure` 思想)。
3
+ *
4
+ * 可恢复失败(网络超时、连接拒绝、缺文件、环境权限)不该进成功率分母,否则会把成功率
5
+ * 拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash 重试
6
+ * 被误判成工具缺陷,把能用的 hashline_edit 禁了)。
7
+ *
8
+ * fail-closed:无 error、或非 Bash 工具,一律按真失败算(证不了可恢复就不排除)。
9
+ */
10
+ const RECOVERABLE_PATTERNS: RegExp[] = [
11
+ /\btimed?\s?out\b/i, // timeout / timed out(网络慢 / 服务死)
12
+ /(ECONNREFUSED|connection refused|not connected|network unreachable)/i, // 服务未起
13
+ /(no such file or directory|ENOENT)/i, // 缺资源,非缺陷
14
+ /(permission denied|EACCES)/i, // 环境权限
15
+ ]
16
+
17
+ export function isRecoverableToolFailure(toolName: string, error?: string): boolean {
18
+ if (!error || toolName !== 'Bash') return false
19
+ const e = error.toLowerCase()
20
+ return RECOVERABLE_PATTERNS.some((re) => re.test(e))
21
+ }
@@ -0,0 +1,52 @@
1
+ // Cron poller — makes the CLI's durable CronCreate jobs actually fire.
2
+ //
3
+ // Every minute it reads ~/.mipham/cron/*.json and, for each job whose
4
+ // nextFire has passed, enqueues the prompt into the engine's cron queue
5
+ // (which re-invokes it into the current session) and then advances the job
6
+ // (recurring → next fire; one-shot → deleted).
7
+
8
+ import { computeNextFire } from './cron'
9
+ import type { CronJob } from '../tools/scheduling/cron'
10
+ import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
11
+
12
+ /** Jobs whose nextFire is at or before `now`. Pure — separated for tests. */
13
+ export function findDueJobs(jobs: CronJob[], now: Date): CronJob[] {
14
+ return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime())
15
+ }
16
+
17
+ /** Next state after firing a due job: recurring advances; one-shot → null (delete). */
18
+ export function advanceJob(job: CronJob, now: Date): CronJob | null {
19
+ if (!job.recurring) return null
20
+ return {
21
+ ...job,
22
+ lastFired: now.toISOString(),
23
+ nextFire: computeNextFire(job.cron, now),
24
+ }
25
+ }
26
+
27
+ /** Read due jobs, enqueue their prompts, and advance/delete. Returns fired count. */
28
+ export function checkCronJobs(enqueue: (prompt: string) => void, now = new Date()): number {
29
+ const due = findDueJobs(readAllJobs(), now)
30
+ for (const job of due) {
31
+ enqueue(job.prompt)
32
+ const next = advanceJob(job, now)
33
+ if (next) writeJob(next)
34
+ else deleteJobFile(job.id)
35
+ }
36
+ return due.length
37
+ }
38
+
39
+ /**
40
+ * Start the cron poller. Returns a stop function. The interval is unref'd so
41
+ * it never keeps the process alive on its own.
42
+ */
43
+ export function startCronPoller(
44
+ enqueue: (prompt: string) => void,
45
+ intervalMs = 60_000,
46
+ ): () => void {
47
+ const timer = setInterval(() => {
48
+ checkCronJobs(enqueue)
49
+ }, intervalMs)
50
+ timer.unref()
51
+ return () => clearInterval(timer)
52
+ }
@@ -0,0 +1,73 @@
1
+ // Simple minute-granularity cron expression parser.
2
+ //
3
+ // Supports the standard 5-field cron format:
4
+ // minute hour day-of-month month day-of-week
5
+ //
6
+ // Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
7
+ // Falls back to +1 minute if the expression is invalid or no match is found
8
+ // within 366 days.
9
+
10
+ export function computeNextFire(cronExpr: string, from: Date): string {
11
+ const fields = cronExpr.trim().split(/\s+/)
12
+ if (fields.length !== 5) {
13
+ return new Date(from.getTime() + 60_000).toISOString()
14
+ }
15
+
16
+ const minField = fields[0]!
17
+ const hourField = fields[1]!
18
+ const domField = fields[2]!
19
+ const monthField = fields[3]!
20
+ const dowField = fields[4]!
21
+
22
+ function matches(value: number, field: string): boolean {
23
+ if (field === '*') return true
24
+ // */N step
25
+ const stepMatch = field.match(/^\*\/(\d+)$/)
26
+ if (stepMatch) {
27
+ return value % parseInt(stepMatch[1]!, 10) === 0
28
+ }
29
+ // Comma-separated list
30
+ if (field.includes(',')) {
31
+ return field.split(',').some((f) => matches(value, f))
32
+ }
33
+ // Range
34
+ const rangeMatch = field.match(/^(\d+)-(\d+)$/)
35
+ if (rangeMatch) {
36
+ const low = parseInt(rangeMatch[1]!, 10)
37
+ const high = parseInt(rangeMatch[2]!, 10)
38
+ return value >= low && value <= high
39
+ }
40
+ // Exact value
41
+ return value === parseInt(field, 10)
42
+ }
43
+
44
+ const date = new Date(from)
45
+ date.setSeconds(0, 0)
46
+ // Start from the next minute to avoid matching the current minute
47
+ date.setMinutes(date.getMinutes() + 1)
48
+
49
+ // Try up to 366 days forward (safety limit — ~527k iterations)
50
+ const maxIterations = 366 * 24 * 60
51
+ for (let i = 0; i < maxIterations; i++) {
52
+ const minute = date.getMinutes()
53
+ const hour = date.getHours()
54
+ const dom = date.getDate()
55
+ const month = date.getMonth() + 1 // JS months are 0-indexed
56
+ const dow = date.getDay() // 0 = Sunday
57
+
58
+ if (
59
+ matches(minute, minField) &&
60
+ matches(hour, hourField) &&
61
+ matches(dom, domField) &&
62
+ matches(month, monthField) &&
63
+ matches(dow, dowField)
64
+ ) {
65
+ return date.toISOString()
66
+ }
67
+
68
+ date.setMinutes(date.getMinutes() + 1)
69
+ }
70
+
71
+ // Fallback: 1 minute from now
72
+ return new Date(from.getTime() + 60_000).toISOString()
73
+ }
@@ -105,6 +105,9 @@ export class QueryEngine {
105
105
  /** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
106
106
  private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
107
107
 
108
+ /** Pending durable cron prompts, enqueued by the cron poller (FIFO — every due job fires). */
109
+ private cronQueue: string[] = []
110
+
108
111
  /** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
109
112
  private onWakeupEnqueued: (() => void) | null = null
110
113
 
@@ -148,6 +151,26 @@ export class QueryEngine {
148
151
  return this.wakeupQueue.length > 0
149
152
  }
150
153
 
154
+ /** Enqueue a durable cron prompt for re-invocation (FIFO — every due job fires). */
155
+ enqueueCronPrompt(prompt: string): void {
156
+ // Dedup by prompt: a recurring job whose previous fire is still queued
157
+ // (slow turn) re-fires the same prompt — skip it rather than pile up.
158
+ if (!this.cronQueue.includes(prompt)) {
159
+ this.cronQueue.push(prompt)
160
+ }
161
+ this.onWakeupEnqueued?.()
162
+ }
163
+
164
+ /** Pop the next pending cron prompt, or null when the queue is empty. */
165
+ dequeueCronPrompt(): string | null {
166
+ return this.cronQueue.shift() ?? null
167
+ }
168
+
169
+ /** Whether a cron prompt is pending re-invocation. */
170
+ hasPendingCron(): boolean {
171
+ return this.cronQueue.length > 0
172
+ }
173
+
151
174
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
152
175
  private sessionId: string = 'session-1'
153
176
 
@@ -1180,7 +1203,10 @@ export class QueryEngine {
1180
1203
  const match = warning.match(/^\[rule:([^\]]+)\]/)
1181
1204
  const ruleId = match?.[1]
1182
1205
  if (ruleId) {
1183
- tracker.recordApplication(ruleId, result.success)
1206
+ tracker.recordApplication(ruleId, result.success, {
1207
+ toolName: name,
1208
+ error: result.error,
1209
+ })
1184
1210
  }
1185
1211
  }
1186
1212
  tracker.persist()
@@ -308,7 +308,7 @@ Never omit it or present the work as purely human-authored.`)
308
308
  const description = modeDescriptions[mode]
309
309
  if (!description) return ''
310
310
 
311
- return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry with the same tool and similar parameters. Move on to a different approach or ask the user for guidance.`
311
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode. If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch to bypassPermissions (Shift+Tab) or add an allow rule (/permissions), then wait for the user's answer.`
312
312
  }
313
313
 
314
314
  list(): InstructionFile[] {
@@ -8,6 +8,7 @@ import {
8
8
  statSync,
9
9
  } from 'node:fs'
10
10
  import { join, extname } from 'node:path'
11
+ import { similarities } from './tfidf'
11
12
 
12
13
  export interface MemoryMetadata {
13
14
  type: 'user' | 'feedback' | 'project' | 'reference'
@@ -113,21 +114,22 @@ export class MemoryManager {
113
114
 
114
115
  recall(context: string, limit: number = 10): MemoryEntry[] {
115
116
  const contextLower = context.toLowerCase()
117
+ const entries = [...this.memories.values()]
118
+ // TF-IDF cosine similarity (CJK-bigram aware) replaces the old word-overlap.
119
+ const sims = similarities(
120
+ context,
121
+ entries.map((e) => e.content),
122
+ )
116
123
  const scored: Array<{ entry: MemoryEntry; score: number }> = []
117
124
  const seen = new Set<string>()
118
125
 
119
- for (const entry of this.memories.values()) {
120
- let score = 0
121
- // Match against relevance tags
126
+ for (let i = 0; i < entries.length; i++) {
127
+ const entry = entries[i]!
128
+ let score = sims[i]! * 10
129
+ // Match against relevance tags (strong curated signal)
122
130
  for (const tag of entry.metadata.relevance) {
123
131
  if (contextLower.includes(tag.toLowerCase())) score += 3
124
132
  }
125
- // Match against content keywords
126
- const contentWords = entry.content.toLowerCase().split(/\s+/)
127
- const contextWords = new Set(contextLower.split(/\s+/))
128
- for (const word of contentWords) {
129
- if (contextWords.has(word) && word.length > 3) score += 1
130
- }
131
133
  if (score > 0) {
132
134
  scored.push({ entry, score })
133
135
  seen.add(entry.name)
@@ -0,0 +1,108 @@
1
+ // Local TF-IDF similarity — zero-dependency "semantic-ish" recall.
2
+ //
3
+ // The built-in memory is local files and must stay offline. This module upgrades
4
+ // the old exact-keyword overlap to TF-IDF-weighted cosine similarity, with a CJK
5
+ // character-bigram tokenizer so Chinese content (which has no word separators)
6
+ // is matched on adjacent-character overlap rather than whole-word equality.
7
+
8
+ const CJK_RE = /[㐀-䶿一-鿿]/
9
+ const WORD_RE = /[a-z0-9]/
10
+
11
+ function isCjk(ch: string): boolean {
12
+ return CJK_RE.test(ch)
13
+ }
14
+
15
+ function isWordChar(ch: string): boolean {
16
+ return WORD_RE.test(ch)
17
+ }
18
+
19
+ /**
20
+ * Tokenize text into CJK character bigrams + lowercase ASCII words (length ≥ 2).
21
+ * Punctuation and single-char ASCII tokens are dropped as noise.
22
+ */
23
+ export function tokenize(text: string): string[] {
24
+ const tokens: string[] = []
25
+ const lower = text.toLowerCase()
26
+ let i = 0
27
+
28
+ while (i < lower.length) {
29
+ const ch = lower[i]!
30
+ if (isCjk(ch)) {
31
+ let run = ''
32
+ while (i < lower.length && isCjk(lower[i]!)) {
33
+ run += lower[i]!
34
+ i++
35
+ }
36
+ if (run.length === 1) {
37
+ tokens.push(run)
38
+ } else {
39
+ for (let j = 0; j < run.length - 1; j++) {
40
+ tokens.push(run.slice(j, j + 2))
41
+ }
42
+ }
43
+ } else if (isWordChar(ch)) {
44
+ let word = ''
45
+ while (i < lower.length && isWordChar(lower[i]!)) {
46
+ word += lower[i]!
47
+ i++
48
+ }
49
+ if (word.length >= 2) tokens.push(word)
50
+ } else {
51
+ i++
52
+ }
53
+ }
54
+
55
+ return tokens
56
+ }
57
+
58
+ /** Cosine similarity between two sparse term-weight vectors. */
59
+ export function cosine(a: Map<string, number>, b: Map<string, number>): number {
60
+ let dot = 0
61
+ let normA = 0
62
+ for (const [term, w] of a) {
63
+ normA += w * w
64
+ const wb = b.get(term)
65
+ if (wb !== undefined) dot += w * wb
66
+ }
67
+ let normB = 0
68
+ for (const w of b.values()) normB += w * w
69
+ if (normA === 0 || normB === 0) return 0
70
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB))
71
+ }
72
+
73
+ /** TF-IDF weight vector for one token list, given corpus document frequency. */
74
+ function tfidfVector(
75
+ tokens: string[],
76
+ df: Map<string, number>,
77
+ numDocs: number,
78
+ ): Map<string, number> {
79
+ const tf = new Map<string, number>()
80
+ for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1)
81
+
82
+ const vec = new Map<string, number>()
83
+ for (const [term, freq] of tf) {
84
+ const idf = Math.log((numDocs + 1) / ((df.get(term) ?? 0) + 1)) + 1
85
+ vec.set(term, freq * idf)
86
+ }
87
+ return vec
88
+ }
89
+
90
+ /**
91
+ * Cosine similarity of a query against each document, index-aligned.
92
+ * `docs` are raw texts; tokenization + TF-IDF weighting is applied internally.
93
+ */
94
+ export function similarities(query: string, docs: string[]): number[] {
95
+ const docTokens = docs.map(tokenize)
96
+ const queryTokens = tokenize(query)
97
+ const numDocs = docs.length
98
+
99
+ const df = new Map<string, number>()
100
+ for (const tokens of docTokens) {
101
+ for (const term of new Set(tokens)) {
102
+ df.set(term, (df.get(term) ?? 0) + 1)
103
+ }
104
+ }
105
+
106
+ const queryVec = tfidfVector(queryTokens, df, numDocs)
107
+ return docTokens.map((tokens) => cosine(queryVec, tfidfVector(tokens, df, numDocs)))
108
+ }
@@ -6,79 +6,7 @@ import type { DaemonDatabase } from './database'
6
6
  import type { WorkerPool } from './worker-pool'
7
7
  import type { DaemonSchedule } from './types'
8
8
  import { logger } from './logger'
9
-
10
- // Simple minute-granularity cron expression parser.
11
- //
12
- // Supports the standard 5-field cron format:
13
- // minute hour day-of-month month day-of-week
14
- //
15
- // Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
16
- // Falls back to +1 minute if the expression is invalid or no match is found
17
- // within 366 days.
18
- function computeNextFire(cronExpr: string, from: Date): string {
19
- const fields = cronExpr.trim().split(/\s+/)
20
- if (fields.length !== 5) {
21
- return new Date(from.getTime() + 60_000).toISOString()
22
- }
23
-
24
- const minField = fields[0]!
25
- const hourField = fields[1]!
26
- const domField = fields[2]!
27
- const monthField = fields[3]!
28
- const dowField = fields[4]!
29
-
30
- function matches(value: number, field: string): boolean {
31
- if (field === '*') return true
32
- // */N step
33
- const stepMatch = field.match(/^\*\/(\d+)$/)
34
- if (stepMatch) {
35
- return value % parseInt(stepMatch[1]!, 10) === 0
36
- }
37
- // Comma-separated list
38
- if (field.includes(',')) {
39
- return field.split(',').some((f) => matches(value, f))
40
- }
41
- // Range
42
- const rangeMatch = field.match(/^(\d+)-(\d+)$/)
43
- if (rangeMatch) {
44
- const low = parseInt(rangeMatch[1]!, 10)
45
- const high = parseInt(rangeMatch[2]!, 10)
46
- return value >= low && value <= high
47
- }
48
- // Exact value
49
- return value === parseInt(field, 10)
50
- }
51
-
52
- const date = new Date(from)
53
- date.setSeconds(0, 0)
54
- // Start from the next minute to avoid matching the current minute
55
- date.setMinutes(date.getMinutes() + 1)
56
-
57
- // Try up to 366 days forward (safety limit — ~527k iterations)
58
- const maxIterations = 366 * 24 * 60
59
- for (let i = 0; i < maxIterations; i++) {
60
- const minute = date.getMinutes()
61
- const hour = date.getHours()
62
- const dom = date.getDate()
63
- const month = date.getMonth() + 1 // JS months are 0-indexed
64
- const dow = date.getDay() // 0 = Sunday
65
-
66
- if (
67
- matches(minute, minField) &&
68
- matches(hour, hourField) &&
69
- matches(dom, domField) &&
70
- matches(month, monthField) &&
71
- matches(dow, dowField)
72
- ) {
73
- return date.toISOString()
74
- }
75
-
76
- date.setMinutes(date.getMinutes() + 1)
77
- }
78
-
79
- // Fallback: 1 minute from now
80
- return new Date(from.getTime() + 60_000).toISOString()
81
- }
9
+ import { computeNextFire } from '../core/cron'
82
10
 
83
11
  export class ScheduleManager {
84
12
  private db: DaemonDatabase
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.61.0' as const
12
+ export const PACKAGE_VERSION = '0.62.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -10,6 +10,7 @@ import { join } from 'node:path'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { homedir } from 'node:os'
12
12
  import type { ToolDefinition } from '../../shared/index.ts'
13
+ import { computeNextFire } from '../../core/cron'
13
14
 
14
15
  const CRON_DIR = join(homedir(), '.mipham', 'cron')
15
16
 
@@ -17,12 +18,14 @@ function ensureCronDir(): void {
17
18
  if (!existsSync(CRON_DIR)) mkdirSync(CRON_DIR, { recursive: true })
18
19
  }
19
20
 
20
- interface CronJob {
21
+ export interface CronJob {
21
22
  id: string
22
23
  cron: string
23
24
  prompt: string
24
25
  recurring: boolean
25
26
  createdAt: string
27
+ nextFire: string
28
+ lastFired: string | null
26
29
  }
27
30
 
28
31
  function jobPath(id: string): string {
@@ -33,13 +36,21 @@ function generateId(cron: string, prompt: string): string {
33
36
  return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
34
37
  }
35
38
 
36
- function readAllJobs(): CronJob[] {
39
+ /**
40
+ * Read all durable cron jobs. Backfills `nextFire`/`lastFired` for files
41
+ * written before the executor landed (they only had id/cron/prompt/recurring).
42
+ */
43
+ export function readAllJobs(): CronJob[] {
37
44
  ensureCronDir()
38
45
  const jobs: CronJob[] = []
39
46
  for (const file of readdirSync(CRON_DIR)) {
40
47
  if (!file.endsWith('.json')) continue
41
48
  try {
42
- jobs.push(JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')))
49
+ const parsed = JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')) as Partial<CronJob>
50
+ if (!parsed.nextFire)
51
+ parsed.nextFire = computeNextFire(parsed.cron ?? '* * * * *', new Date())
52
+ if (parsed.lastFired === undefined) parsed.lastFired = null
53
+ jobs.push(parsed as CronJob)
43
54
  } catch {
44
55
  /* skip corrupt files */
45
56
  }
@@ -47,6 +58,20 @@ function readAllJobs(): CronJob[] {
47
58
  return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
48
59
  }
49
60
 
61
+ /** Persist a job (create or update) to its JSON file. */
62
+ export function writeJob(job: CronJob): void {
63
+ ensureCronDir()
64
+ writeFileSync(jobPath(job.id), JSON.stringify(job, null, 2), 'utf-8')
65
+ }
66
+
67
+ /** Delete a job's file. Returns false when the job did not exist. */
68
+ export function deleteJobFile(id: string): boolean {
69
+ const path = jobPath(id)
70
+ if (!existsSync(path)) return false
71
+ unlinkSync(path)
72
+ return true
73
+ }
74
+
50
75
  export const cronCreateTool: ToolDefinition = {
51
76
  name: 'CronCreate',
52
77
  description:
@@ -82,17 +107,18 @@ export const cronCreateTool: ToolDefinition = {
82
107
  const recurring = params.recurring !== false
83
108
  const id = generateId(cron, prompt)
84
109
 
85
- ensureCronDir()
86
-
110
+ const now = new Date()
87
111
  const job: CronJob = {
88
112
  id,
89
113
  cron,
90
114
  prompt: prompt.slice(0, 1000),
91
115
  recurring,
92
- createdAt: new Date().toISOString(),
116
+ createdAt: now.toISOString(),
117
+ nextFire: computeNextFire(cron, now),
118
+ lastFired: null,
93
119
  }
94
120
 
95
- writeFileSync(jobPath(id), JSON.stringify(job, null, 2), 'utf-8')
121
+ writeJob(job)
96
122
 
97
123
  const type = recurring ? 'recurring' : 'one-shot'
98
124
  return {
@@ -124,11 +150,9 @@ export const cronDeleteTool: ToolDefinition = {
124
150
  },
125
151
  async execute(params, _ctx) {
126
152
  const id = params.id as string
127
- const path = jobPath(id)
128
- if (!existsSync(path)) {
153
+ if (!deleteJobFile(id)) {
129
154
  return { success: false, content: '', error: `Cron job "${id}" not found.` }
130
155
  }
131
- unlinkSync(path)
132
156
  return { success: true, content: `Cron job "${id}" deleted.` }
133
157
  },
134
158
  }
package/src/ui/app.tsx CHANGED
@@ -52,6 +52,7 @@ import type { PermissionMode } from '../shared/index.ts'
52
52
  import { sanitizeForDisplay } from '../shared/sanitize.ts'
53
53
  import { recordLoopTurn, readAutoloopJournal } from '../commands/autoloop-journal.js'
54
54
  import { cancelAllSessionTimers } from '../tools/scheduling/schedule-wakeup.js'
55
+ import { startCronPoller } from '../core/cron-poller'
55
56
 
56
57
  interface AppProps {
57
58
  engine: QueryEngine | RemoteEngine
@@ -686,9 +687,16 @@ export function App({
686
687
  // User turn wins: if a user submitted meanwhile, turnId advanced and this loop wakeup
687
688
  // yields (the next timer fire re-enqueues). Spec §六 — do not force it to run anyway.
688
689
  if (turnIdRef.current !== turnId) return
689
- // RemoteEngine (remote attach mode) has no wakeup queue — /loop is CLI-local.
690
+ // RemoteEngine (remote attach mode) has no wakeup queue — /loop + cron are CLI-local.
690
691
  if (!('hasPendingWakeup' in engine)) return
691
- const next = engine.hasPendingWakeup() ? engine.dequeueWakeup() : null
692
+ // Cron first (absolute-time scheduled, FIFO — every due job fires), then /loop.
693
+ const cronPrompt = engine.hasPendingCron() ? engine.dequeueCronPrompt() : null
694
+ const next =
695
+ cronPrompt !== null
696
+ ? { prompt: cronPrompt, noop: false }
697
+ : engine.hasPendingWakeup()
698
+ ? engine.dequeueWakeup()
699
+ : null
692
700
  if (next) await runTurn(next.prompt, 'loop', undefined, next.noop)
693
701
  }
694
702
 
@@ -713,6 +721,17 @@ export function App({
713
721
  drainLoopQueueRef.current?.(turnIdRef.current)
714
722
  }, [wakeupTick, isLoading])
715
723
 
724
+ // ── cron poller ──
725
+ // Start the durable-cron poller so CronCreate jobs actually fire into this session.
726
+ // It enqueues due prompts into the engine's cron queue; the drain above re-invokes
727
+ // them. Cron is CLI-local (RemoteEngine has no cron queue), like /loop.
728
+ useEffect(() => {
729
+ if (!('hasPendingWakeup' in engine)) return
730
+ const qe = engine // narrow to QueryEngine for the cleanup closure
731
+ const stop = startCronPoller((prompt) => qe.enqueueCronPrompt(prompt))
732
+ return stop
733
+ }, [engine])
734
+
716
735
  const handleSubmit = useCallback(
717
736
  async (input: string) => {
718
737
  if (!input.trim()) return