@miphamai/cli 0.61.0 → 0.63.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 +1 -1
- package/src/agent/effectiveness-tracker.ts +10 -1
- package/src/agent/message-bus.ts +10 -0
- package/src/agent/recoverable-failure.ts +27 -0
- package/src/agent/sub-agent.ts +15 -3
- package/src/core/cron-poller.ts +52 -0
- package/src/core/cron.ts +73 -0
- package/src/core/crsi-sandbox.ts +25 -5
- package/src/core/engine.ts +56 -2
- package/src/core/hooks-executor.ts +2 -1
- package/src/core/instructions.ts +1 -1
- package/src/core/memory/memory-manager.ts +11 -9
- package/src/core/memory/tfidf.ts +108 -0
- package/src/core/session-name.ts +12 -0
- package/src/daemon/schedule-manager.ts +1 -73
- package/src/index.tsx +20 -8
- package/src/mcp/connect-failures.ts +15 -0
- package/src/plugin/claude-plugin.ts +1 -1
- package/src/plugin/plugin-validator.ts +1 -1
- package/src/shared/package-info.ts +1 -1
- package/src/tools/scheduling/cron.ts +34 -10
- package/src/ui/app.tsx +21 -2
package/package.json
CHANGED
|
@@ -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,7 @@ export class EffectivenessTracker {
|
|
|
44
47
|
if (eff) eff.decisionId = decisionId
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
recordApplication(ruleId: string, success: boolean): void {
|
|
50
|
+
recordApplication(ruleId: string, success: boolean, opts?: { error?: string }): void {
|
|
48
51
|
let eff = this.data.get(ruleId)
|
|
49
52
|
if (!eff) {
|
|
50
53
|
eff = {
|
|
@@ -61,6 +64,12 @@ export class EffectivenessTracker {
|
|
|
61
64
|
this.data.set(ruleId, eff)
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
// 可恢复/环境性失败:仍记录,但不进成功率分母(防误降级/误禁用能用的规则)。
|
|
68
|
+
if (!success && opts?.error && isRecoverableToolFailure(opts.error)) {
|
|
69
|
+
eff.recoverableCount = (eff.recoverableCount ?? 0) + 1
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
eff.appliedCount++
|
|
65
74
|
if (success) eff.successAfterCount++
|
|
66
75
|
|
package/src/agent/message-bus.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface AgentMessage {
|
|
|
24
24
|
type: AgentMessageType
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Format an inbound message for injection into the conversation as a
|
|
29
|
+
* user-role notice. Collapsed to one line (`Message from @<sender>: <summary>`)
|
|
30
|
+
* so cross-session peer messages don't bloat the context; the full body stays
|
|
31
|
+
* in the bus for Ctrl+O expansion.
|
|
32
|
+
*/
|
|
33
|
+
export function formatInboundMessage(msg: AgentMessage): string {
|
|
34
|
+
return `Message from @${msg.from}: ${msg.summary}`
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
export class AgentMessageBus {
|
|
28
38
|
private messages: AgentMessage[] = []
|
|
29
39
|
private idCounter = 0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 判「可恢复/环境性」失败 vs 真缺陷(borrow openprabs is_recoverable_tool_failure 思想)。
|
|
3
|
+
*
|
|
4
|
+
* 可恢复失败(网络超时、连接拒绝、DNS 解析失败、环境权限)不该进成功率分母,否则会把
|
|
5
|
+
* 成功率拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash
|
|
6
|
+
* 重试被误判成工具缺陷,把能用的 hashline_edit 禁了)。
|
|
7
|
+
*
|
|
8
|
+
* 刻意排除 ENOENT(no such file or directory):缺文件/路径错更可能是规则本身指错了
|
|
9
|
+
* 路径(幻觉),而非环境瞬态,须计入分母(2026-08-27 review M1)。
|
|
10
|
+
*
|
|
11
|
+
* 不区分工具:这些错误模式(超时/连接/DNS/权限)本身即工具无关的环境信号。旧的
|
|
12
|
+
* `toolName !== 'Bash'` 守卫漏掉 Write/Grep 规则(2026-08-27 review M2),已移除。
|
|
13
|
+
*
|
|
14
|
+
* fail-closed:无 error 一律按真失败算(证不了可恢复就不排除)。
|
|
15
|
+
*/
|
|
16
|
+
const RECOVERABLE_PATTERNS: RegExp[] = [
|
|
17
|
+
/\btimed?\s?out\b/i, // timeout / timed out / timedout(网络慢 / 服务慢)
|
|
18
|
+
/(ECONNREFUSED|ECONNRESET|ETIMEDOUT|connection refused|not connected|network unreachable)/i, // 服务未起 / 网络超时
|
|
19
|
+
/(could not resolve host|name or service not known|getaddrinfo|ENOTFOUND)/i, // DNS 解析失败
|
|
20
|
+
/(permission denied|EACCES)/i, // 环境权限
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export function isRecoverableToolFailure(error?: string): boolean {
|
|
24
|
+
if (!error) return false
|
|
25
|
+
const e = error.toLowerCase()
|
|
26
|
+
return RECOVERABLE_PATTERNS.some((re) => re.test(e))
|
|
27
|
+
}
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -319,6 +319,7 @@ export class SubAgent {
|
|
|
319
319
|
|
|
320
320
|
const chunks: string[] = []
|
|
321
321
|
const MAX_TOOL_TURNS = options.maxTurns || 5
|
|
322
|
+
let hitMaxTurns = false
|
|
322
323
|
|
|
323
324
|
let currentMessages = messages
|
|
324
325
|
let currentSystemPrompt = systemPrompt
|
|
@@ -360,7 +361,7 @@ export class SubAgent {
|
|
|
360
361
|
})
|
|
361
362
|
}
|
|
362
363
|
if (chunk.type === 'error') {
|
|
363
|
-
throw new Error(`Sub-agent execution failed: ${chunk.error}`)
|
|
364
|
+
throw new Error(`Sub-agent execution failed (model ${finalModel}): ${chunk.error}`)
|
|
364
365
|
}
|
|
365
366
|
if (chunk.type === 'stop') {
|
|
366
367
|
break
|
|
@@ -501,6 +502,9 @@ export class SubAgent {
|
|
|
501
502
|
|
|
502
503
|
// Don't send system prompt on subsequent turns
|
|
503
504
|
currentSystemPrompt = ''
|
|
505
|
+
|
|
506
|
+
// On the final permitted turn we still used tools → hit the cap.
|
|
507
|
+
if (turn === MAX_TOOL_TURNS - 1) hitMaxTurns = true
|
|
504
508
|
}
|
|
505
509
|
} catch (err) {
|
|
506
510
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
@@ -509,10 +513,18 @@ export class SubAgent {
|
|
|
509
513
|
if (err instanceof Error && err.message.startsWith('Sub-agent')) {
|
|
510
514
|
throw err
|
|
511
515
|
}
|
|
512
|
-
throw new Error(`Sub-agent execution failed: ${String(err)}`)
|
|
516
|
+
throw new Error(`Sub-agent execution failed (model ${finalModel}): ${String(err)}`)
|
|
513
517
|
}
|
|
514
518
|
|
|
515
|
-
|
|
519
|
+
const result = chunks.join('')
|
|
520
|
+
if (hitMaxTurns) {
|
|
521
|
+
return (
|
|
522
|
+
`[partial result — sub-agent reached its ${MAX_TOOL_TURNS}-turn limit; ` +
|
|
523
|
+
'task may be incomplete. Use SendMessage to continue this sub-agent.]\n\n' +
|
|
524
|
+
result
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
return result
|
|
516
528
|
}
|
|
517
529
|
|
|
518
530
|
/**
|
|
@@ -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
|
+
}
|
package/src/core/cron.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { execSync } from 'node:child_process'
|
|
18
18
|
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
|
|
19
|
-
import { join, resolve, sep } from 'node:path'
|
|
19
|
+
import { join, resolve, sep, posix } from 'node:path'
|
|
20
20
|
import { tmpdir, homedir } from 'node:os'
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
22
22
|
|
|
@@ -115,6 +115,12 @@ const PROTECTED_PATHS = [
|
|
|
115
115
|
'apps/cli/src/core/crsi-sandbox.ts',
|
|
116
116
|
'apps/cli/src/core/crsi-producer.ts',
|
|
117
117
|
'apps/cli/src/core/proposal-guard.ts',
|
|
118
|
+
// 2026-08-27 review 补齐:运行时执行/评估器,改掉会削弱 grader 或安全边界
|
|
119
|
+
'apps/cli/src/core/crsi-modify.ts',
|
|
120
|
+
'apps/cli/src/core/rule-engine.ts',
|
|
121
|
+
'apps/cli/src/core/red-team.ts',
|
|
122
|
+
'apps/cli/src/core/error-signature-db.ts',
|
|
123
|
+
'apps/cli/src/core/preflight-checker.ts',
|
|
118
124
|
]
|
|
119
125
|
|
|
120
126
|
/** 是否命中只读边界。前缀匹配,目录条目以 `/` 结尾。 */
|
|
@@ -130,13 +136,24 @@ export function isProtectedPath(filePath: string): boolean {
|
|
|
130
136
|
*
|
|
131
137
|
* 返回错误字符串(拒绝理由),合法时返回 null。
|
|
132
138
|
*/
|
|
133
|
-
export function validateBlastRadius(proposal: {
|
|
139
|
+
export function validateBlastRadius(proposal: {
|
|
140
|
+
filePath?: string
|
|
141
|
+
blastRadius?: string[]
|
|
142
|
+
}): string | null {
|
|
143
|
+
const filePath = proposal.filePath
|
|
134
144
|
if (!proposal.blastRadius || proposal.blastRadius.length === 0) {
|
|
135
145
|
return (
|
|
136
146
|
'blast radius 未声明:自修改必须枚举触及的全部代码路径。' +
|
|
137
147
|
'教训:两条渲染路径只接一条 = 局部正确全局遗漏。'
|
|
138
148
|
)
|
|
139
149
|
}
|
|
150
|
+
// 2026-08-27 review:blastRadius 不能只自证「非空」,必须覆盖被修改文件本身
|
|
151
|
+
// (前缀匹配,目录条目以 / 结尾),否则声明形同虚设。
|
|
152
|
+
if (filePath && !proposal.blastRadius.some((p) => filePath === p || filePath.startsWith(p))) {
|
|
153
|
+
return (
|
|
154
|
+
`blast radius 未覆盖目标文件 "${filePath}":` + '声明必须包含被修改文件本身(前缀匹配)。'
|
|
155
|
+
)
|
|
156
|
+
}
|
|
140
157
|
return null
|
|
141
158
|
}
|
|
142
159
|
|
|
@@ -225,7 +242,10 @@ export class CrsiSandbox {
|
|
|
225
242
|
}
|
|
226
243
|
|
|
227
244
|
const worktreeRoot = resolve(this.worktreePath)
|
|
228
|
-
|
|
245
|
+
// Normalize "./" and "subdir/../" so the protected-path guard can't be evaded
|
|
246
|
+
// by a path that resolves back inside the worktree (raw prefix match would miss it).
|
|
247
|
+
const normalizedFilePath = posix.normalize(mod.filePath)
|
|
248
|
+
const targetPath = resolve(this.worktreePath, normalizedFilePath)
|
|
229
249
|
|
|
230
250
|
// Path traversal guard: reject any target that resolves outside the sandbox worktree
|
|
231
251
|
if (targetPath !== worktreeRoot && !targetPath.startsWith(worktreeRoot + sep)) {
|
|
@@ -237,7 +257,7 @@ export class CrsiSandbox {
|
|
|
237
257
|
|
|
238
258
|
// Protected-path guard: the self-improvement loop must not modify the
|
|
239
259
|
// constitution, eval harness, or improvement machinery itself.
|
|
240
|
-
if (isProtectedPath(
|
|
260
|
+
if (isProtectedPath(normalizedFilePath)) {
|
|
241
261
|
result.error = `Protected path: "${mod.filePath}" is read-only to the self-improvement loop.`
|
|
242
262
|
result.phase = 'failed'
|
|
243
263
|
this.sessionReport.modifications.push(result)
|
|
@@ -275,7 +295,7 @@ export class CrsiSandbox {
|
|
|
275
295
|
|
|
276
296
|
// Generate diff
|
|
277
297
|
try {
|
|
278
|
-
result.diff = execSync(`git diff -- "${
|
|
298
|
+
result.diff = execSync(`git diff -- "${normalizedFilePath}"`, {
|
|
279
299
|
cwd: this.worktreePath,
|
|
280
300
|
timeout: 10_000,
|
|
281
301
|
encoding: 'utf-8',
|
package/src/core/engine.ts
CHANGED
|
@@ -40,7 +40,12 @@ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './infe
|
|
|
40
40
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
41
41
|
import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
|
|
42
42
|
import { accumulateGraftSavings } from '../shared/graft-savings'
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
getMessageBus,
|
|
45
|
+
formatInboundMessage,
|
|
46
|
+
type AgentMessage,
|
|
47
|
+
type AgentMessageBus,
|
|
48
|
+
} from '../agent/message-bus'
|
|
44
49
|
import { createT } from '../i18n-core/t'
|
|
45
50
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
46
51
|
import zhCN from '../i18n-core/locales/zh-CN.json'
|
|
@@ -105,6 +110,9 @@ export class QueryEngine {
|
|
|
105
110
|
/** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
|
|
106
111
|
private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
|
|
107
112
|
|
|
113
|
+
/** Pending durable cron prompts, enqueued by the cron poller (FIFO — every due job fires). */
|
|
114
|
+
private cronQueue: string[] = []
|
|
115
|
+
|
|
108
116
|
/** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
|
|
109
117
|
private onWakeupEnqueued: (() => void) | null = null
|
|
110
118
|
|
|
@@ -148,6 +156,26 @@ export class QueryEngine {
|
|
|
148
156
|
return this.wakeupQueue.length > 0
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
/** Enqueue a durable cron prompt for re-invocation (FIFO — every due job fires). */
|
|
160
|
+
enqueueCronPrompt(prompt: string): void {
|
|
161
|
+
// Dedup by prompt: a recurring job whose previous fire is still queued
|
|
162
|
+
// (slow turn) re-fires the same prompt — skip it rather than pile up.
|
|
163
|
+
if (!this.cronQueue.includes(prompt)) {
|
|
164
|
+
this.cronQueue.push(prompt)
|
|
165
|
+
}
|
|
166
|
+
this.onWakeupEnqueued?.()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Pop the next pending cron prompt, or null when the queue is empty. */
|
|
170
|
+
dequeueCronPrompt(): string | null {
|
|
171
|
+
return this.cronQueue.shift() ?? null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether a cron prompt is pending re-invocation. */
|
|
175
|
+
hasPendingCron(): boolean {
|
|
176
|
+
return this.cronQueue.length > 0
|
|
177
|
+
}
|
|
178
|
+
|
|
151
179
|
/** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
|
|
152
180
|
private sessionId: string = 'session-1'
|
|
153
181
|
|
|
@@ -232,6 +260,28 @@ export class QueryEngine {
|
|
|
232
260
|
}
|
|
233
261
|
}
|
|
234
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Drain unread inbound messages from the in-memory bus (addressed to this
|
|
265
|
+
* session or to "main") and inject them into the conversation as user-role
|
|
266
|
+
* notices. Returns the number of messages injected.
|
|
267
|
+
*
|
|
268
|
+
* The bus is the delivery channel for same-process SendMessage ("main",
|
|
269
|
+
* "bg-*", "sub-agent-*") and the re-post target of pollCrossSessionInbox;
|
|
270
|
+
* this is the missing "drain" step that surfaces those messages to the model.
|
|
271
|
+
*/
|
|
272
|
+
drainInboundMessages(bus: AgentMessageBus = getMessageBus()): number {
|
|
273
|
+
const recipients = [...new Set([this.sessionId, 'main'])]
|
|
274
|
+
let injected = 0
|
|
275
|
+
for (const recipient of recipients) {
|
|
276
|
+
for (const msg of bus.poll(recipient)) {
|
|
277
|
+
this.context.addMessage({ role: 'user', content: formatInboundMessage(msg) })
|
|
278
|
+
injected++
|
|
279
|
+
}
|
|
280
|
+
bus.markAllRead(recipient)
|
|
281
|
+
}
|
|
282
|
+
return injected
|
|
283
|
+
}
|
|
284
|
+
|
|
235
285
|
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
236
286
|
setHookEngine(hooks: HookEngine): void {
|
|
237
287
|
this.hookEngine = hooks
|
|
@@ -451,6 +501,8 @@ export class QueryEngine {
|
|
|
451
501
|
|
|
452
502
|
// Poll for cross-session messages before each turn
|
|
453
503
|
await this.pollCrossSessionInbox()
|
|
504
|
+
// Drain in-memory bus messages (same-process SendMessage + cross-session re-posts)
|
|
505
|
+
this.drainInboundMessages()
|
|
454
506
|
|
|
455
507
|
// Fire UserPromptSubmit hooks before processing
|
|
456
508
|
if (this.hookEngine) {
|
|
@@ -1180,7 +1232,9 @@ export class QueryEngine {
|
|
|
1180
1232
|
const match = warning.match(/^\[rule:([^\]]+)\]/)
|
|
1181
1233
|
const ruleId = match?.[1]
|
|
1182
1234
|
if (ruleId) {
|
|
1183
|
-
tracker.recordApplication(ruleId, result.success
|
|
1235
|
+
tracker.recordApplication(ruleId, result.success, {
|
|
1236
|
+
error: result.error,
|
|
1237
|
+
})
|
|
1184
1238
|
}
|
|
1185
1239
|
}
|
|
1186
1240
|
tracker.persist()
|
|
@@ -49,7 +49,8 @@ function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// Non-zero exit: check for block signal (exit code 2)
|
|
52
|
-
|
|
52
|
+
// 截断 stderr 防 MB 级 hook 输出溢出会话(对齐 HTTP hook 的 slice(0,2000))。
|
|
53
|
+
const stderr = (result.stderr?.toString() || '').slice(0, 2000)
|
|
53
54
|
|
|
54
55
|
if (result.status === 2) {
|
|
55
56
|
return {
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the session name (ID) used for cross-session tracking, inbox routing,
|
|
3
|
+
* and session persistence.
|
|
4
|
+
*
|
|
5
|
+
* Includes milliseconds so two sessions launched in the same second do not
|
|
6
|
+
* collide on the shared inbox directory / active-session registry. (2026-08-27
|
|
7
|
+
* review: the old `slice(0, 19)` dropped ms and let same-second launches share
|
|
8
|
+
* one inbox, leaking / misdelivering cross-session messages.)
|
|
9
|
+
*/
|
|
10
|
+
export function generateSessionName(resume: string | undefined, now: Date = new Date()): string {
|
|
11
|
+
return resume || `session-${now.toISOString().replace(/[:.]/g, '-')}`
|
|
12
|
+
}
|
|
@@ -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
|
package/src/index.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { loadSessionMemories, getMemoryManager } from './core/memory/memory-load
|
|
|
25
25
|
import { ContextManager } from './core/context'
|
|
26
26
|
import { PrefixCacheTracker } from './core/context-token'
|
|
27
27
|
import { QueryEngine } from './core/engine'
|
|
28
|
+
import { generateSessionName } from './core/session-name'
|
|
28
29
|
import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
29
30
|
import { SessionLog } from './core/session-log'
|
|
30
31
|
import { SessionStore } from './core/session-store'
|
|
@@ -40,6 +41,7 @@ import { mountConstitution, createConstitution } from './core/constitution-seam'
|
|
|
40
41
|
import { ConstitutionLoader } from './core/constitution-loader'
|
|
41
42
|
import { McpClient } from './mcp/client'
|
|
42
43
|
import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
|
|
44
|
+
import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
|
|
43
45
|
import { AgentRegistry } from './agent/agent-registry'
|
|
44
46
|
import { HookEngine } from './core/hooks'
|
|
45
47
|
import { ArtifactServer } from './artifacts/server'
|
|
@@ -202,8 +204,8 @@ function SetupGate(props: SetupGateProps) {
|
|
|
202
204
|
async function connectMcpServers(
|
|
203
205
|
mcpServers: McpServerConfig[],
|
|
204
206
|
tools: ReturnType<typeof createToolRegistry>,
|
|
205
|
-
): Promise<
|
|
206
|
-
if (mcpServers.length === 0) return
|
|
207
|
+
): Promise<McpConnectFailure[]> {
|
|
208
|
+
if (mcpServers.length === 0) return []
|
|
207
209
|
const mcp = McpClient.getInstance()
|
|
208
210
|
// Wire runtime tool-list changes before connect so mid-connect updates land.
|
|
209
211
|
syncMcpToolsOnChange(mcp, tools)
|
|
@@ -216,14 +218,17 @@ async function connectMcpServers(
|
|
|
216
218
|
}
|
|
217
219
|
}),
|
|
218
220
|
)
|
|
221
|
+
const failures: McpConnectFailure[] = []
|
|
219
222
|
for (let i = 0; i < results.length; i++) {
|
|
220
223
|
const result = results[i]!
|
|
221
224
|
if (result.status === 'rejected') {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
+
const name = mcpServers[i]!.name
|
|
226
|
+
const reason = String(result.reason)
|
|
227
|
+
failures.push({ name, reason })
|
|
228
|
+
process.stderr.write(`[mcp] Failed to connect "${name}": ${reason}\n`)
|
|
225
229
|
}
|
|
226
230
|
}
|
|
231
|
+
return failures
|
|
227
232
|
}
|
|
228
233
|
|
|
229
234
|
export async function runApp(options: RunOptions): Promise<void> {
|
|
@@ -325,8 +330,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
325
330
|
const pluginManager = new PluginManager()
|
|
326
331
|
|
|
327
332
|
// Generate session name for tracking (used by /cd to persist cwd)
|
|
328
|
-
const sessionName =
|
|
329
|
-
options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
333
|
+
const sessionName = generateSessionName(options.resume)
|
|
330
334
|
|
|
331
335
|
// Initialize context — restore saved session if available
|
|
332
336
|
// Read the active model's context window for dynamic max-token sizing.
|
|
@@ -498,7 +502,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
498
502
|
// Connect MCP servers lazily in the background: the UI renders immediately,
|
|
499
503
|
// and each server's tools register into the shared registry as it connects.
|
|
500
504
|
// A dead server (e.g. a 15s connect timeout) no longer blocks startup.
|
|
501
|
-
|
|
505
|
+
const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
|
|
502
506
|
|
|
503
507
|
// Initialize hook engine — register skill-defined hooks
|
|
504
508
|
const hookEngine = new HookEngine()
|
|
@@ -562,6 +566,14 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
562
566
|
|
|
563
567
|
// ── Cross-session messaging: register session, heartbeat, shutdown ──
|
|
564
568
|
engine.setSessionId(sessionName)
|
|
569
|
+
|
|
570
|
+
// Surface MCP connect failures to the model once they settle, so it knows
|
|
571
|
+
// those servers' tools are unavailable (rather than silently concluding the
|
|
572
|
+
// tools don't exist). Injected asynchronously — never blocks first paint.
|
|
573
|
+
void mcpConnectPromise.then((failures) => {
|
|
574
|
+
const notice = formatMcpConnectFailures(failures)
|
|
575
|
+
if (notice) engine.getContext().addMessage({ role: 'user', content: notice })
|
|
576
|
+
})
|
|
565
577
|
// Human-readable, cross-session-addressable name. Defaults to the cwd basename
|
|
566
578
|
// so `@mipham-code` can address this session; uniqueness is enforced against
|
|
567
579
|
// other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface McpConnectFailure {
|
|
2
|
+
name: string
|
|
3
|
+
reason: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a startup notice for MCP servers that failed to connect, so the model
|
|
8
|
+
* knows their tools are unavailable (instead of concluding the tools don't
|
|
9
|
+
* exist). Empty string when nothing failed.
|
|
10
|
+
*/
|
|
11
|
+
export function formatMcpConnectFailures(failures: McpConnectFailure[]): string {
|
|
12
|
+
if (failures.length === 0) return ''
|
|
13
|
+
const list = failures.map((f) => `- ${f.name}: ${f.reason}`).join('\n')
|
|
14
|
+
return `[MCP] These MCP servers failed to connect — their tools are unavailable:\n${list}`
|
|
15
|
+
}
|
|
@@ -26,7 +26,7 @@ export function readClaudeManifest(dir: string): ClaudeManifest | null {
|
|
|
26
26
|
const path = join(dir, '.claude-plugin', 'plugin.json')
|
|
27
27
|
if (!existsSync(path)) return null
|
|
28
28
|
try {
|
|
29
|
-
return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
|
|
29
|
+
return JSON.parse(readFileSync(path, 'utf-8').replace(/^\uFEFF/, '')) as ClaudeManifest
|
|
30
30
|
} catch {
|
|
31
31
|
return null
|
|
32
32
|
}
|
|
@@ -52,7 +52,7 @@ export function validatePlugin(dir: string): PluginValidation {
|
|
|
52
52
|
|
|
53
53
|
const errors: string[] = []
|
|
54
54
|
try {
|
|
55
|
-
const raw = readFileSync(resolved.path, 'utf-8')
|
|
55
|
+
const raw = readFileSync(resolved.path, 'utf-8').replace(/^\uFEFF/, '')
|
|
56
56
|
const manifest = JSON.parse(raw) as PluginManifest
|
|
57
57
|
|
|
58
58
|
if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.63.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
116
|
+
createdAt: now.toISOString(),
|
|
117
|
+
nextFire: computeNextFire(cron, now),
|
|
118
|
+
lastFired: null,
|
|
93
119
|
}
|
|
94
120
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
690
|
+
// RemoteEngine (remote attach mode) has no wakeup queue — /loop + cron are CLI-local.
|
|
690
691
|
if (!('hasPendingWakeup' in engine)) return
|
|
691
|
-
|
|
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
|