@miphamai/cli 0.85.4 → 0.85.6
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 +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +61 -6
- package/src/core/dream-engine.ts +17 -2
- package/src/core/engine.ts +95 -34
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/launch.ts +69 -2
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +86 -143
- package/src/i18n-core/locales/zh-CN.json +86 -143
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +159 -124
- package/src/providers/fetch-utils.ts +65 -34
- package/src/providers/openai-compat.ts +121 -106
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +290 -146
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +20 -6
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
|
@@ -57,15 +57,27 @@ import type { PermissionMode } from '../shared/index.ts'
|
|
|
57
57
|
export const PROMPT_VERSION = 'mipham-auto-classifier/1'
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Milliseconds before a ruling is abandoned.
|
|
61
|
-
*
|
|
60
|
+
* Milliseconds before a ruling is abandoned.
|
|
61
|
+
*
|
|
62
|
+
* This used to be declared as "same bound as `self-critique.ts`" — that pairing
|
|
63
|
+
* is gone, and deliberately not restored in either direction. The two are both
|
|
64
|
+
* secondary model calls, but they fail in *opposite* directions: `self-critique`
|
|
65
|
+
* fails **open** (a timeout ⇒ `null` ⇒ the tool runs), so its budget is bounded
|
|
66
|
+
* by "how often do we want the critique to actually happen"; this one fails
|
|
67
|
+
* **closed**, so its budget is bounded by "how long may a legitimate call be
|
|
68
|
+
* refused for". A budget derived from the fail-open side would be a budget
|
|
69
|
+
* derived from the wrong question.
|
|
62
70
|
*
|
|
63
71
|
* A tighter bound was considered (it is on the gated path, so every ruled call
|
|
64
72
|
* costs the user the full wait) and rejected: with a fail-closed default, a
|
|
65
73
|
* timeout is indistinguishable from a denial to the user, so shrinking this
|
|
66
|
-
* trades "slow" for "auto mode intermittently refuses legitimate work" — and
|
|
67
|
-
*
|
|
68
|
-
*
|
|
74
|
+
* trades "slow" for "auto mode intermittently refuses legitimate work" — and the
|
|
75
|
+
* classifier's own latency distribution still has not been measured. (A sibling
|
|
76
|
+
* measurement does now exist — `self-critique`'s, median 3.95s over 30 real
|
|
77
|
+
* calls — and it is a reason to *distrust* this 2s, not a reading that may be
|
|
78
|
+
* substituted for one.) Making it configurable, or re-basing it, needs that
|
|
79
|
+
* measurement first; the prompts, the target model and the output shape all
|
|
80
|
+
* differ from the sibling.
|
|
69
81
|
*/
|
|
70
82
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 2000
|
|
71
83
|
|
|
@@ -362,7 +362,7 @@ function extractSubstitutions(command: string): string[] {
|
|
|
362
362
|
* REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately — and
|
|
363
363
|
* `bash -c 'rm -rf /'`. Over-matching is the safe direction for a deny rule.
|
|
364
364
|
*/
|
|
365
|
-
function flattenCommand(command: string, depth = 0): string[] {
|
|
365
|
+
export function flattenCommand(command: string, depth = 0): string[] {
|
|
366
366
|
const out: string[] = []
|
|
367
367
|
for (const seg of splitShellSegments(command)) {
|
|
368
368
|
out.push(seg)
|
package/src/core/permission.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
|
+
import { detectDangerousRm } from '../security/dangerous-rm'
|
|
11
|
+
import type { DangerousRm } from '../security/dangerous-rm'
|
|
10
12
|
import {
|
|
11
13
|
loadPermissionConfig,
|
|
12
14
|
nextMode,
|
|
@@ -123,6 +125,7 @@ export type PermissionDenialReason =
|
|
|
123
125
|
| 'tool-default' // tool.permission === 'ask'
|
|
124
126
|
| 'system-default' // no rule, no tool permission → fallback ask
|
|
125
127
|
| 'classifier-deny' // `auto` mode's classifier ruled against the call
|
|
128
|
+
| 'dangerous-rm' // recursive rm whose target is not a path in the command text
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
131
|
* Which denial reasons `auto` mode's classifier is allowed to rule on — an
|
|
@@ -138,6 +141,11 @@ export type PermissionDenialReason =
|
|
|
138
141
|
* `legacy-rule` is absent for the same reason as the rules: it is an explicit
|
|
139
142
|
* per-tool decision from `setRule()`. `classifier-deny` is absent because it is not
|
|
140
143
|
* a *static* reason at all — `explainDenial()` never returns it.
|
|
144
|
+
*
|
|
145
|
+
* `dangerous-rm` is absent because a classifier cannot be *asked* the question this
|
|
146
|
+
* reason answers. Every other reason here is "the mode was not sure, let a second
|
|
147
|
+
* opinion decide"; this one is "the command does not say what it will delete", and
|
|
148
|
+
* a second opinion reading the same command is reading the same missing text.
|
|
141
149
|
*/
|
|
142
150
|
const CLASSIFIABLE: ReadonlySet<PermissionDenialReason> = new Set<PermissionDenialReason>([
|
|
143
151
|
'mode-baseline',
|
|
@@ -463,6 +471,21 @@ export class PermissionSystem {
|
|
|
463
471
|
return 'ask' // absent tool → safest default
|
|
464
472
|
}
|
|
465
473
|
|
|
474
|
+
// ── A recursive `rm` whose target is not a path written in the command ──
|
|
475
|
+
// First, and deliberately **uncached**. Every other branch below reasons about
|
|
476
|
+
// the command's text; this is the one case where the text does not name what
|
|
477
|
+
// gets deleted, so it has to sit ahead of the allow rules and ahead of every
|
|
478
|
+
// mode baseline (`auto`, `bypassPermissions`) rather than inside them.
|
|
479
|
+
//
|
|
480
|
+
// Not cached because its answer depends on the environment opt-out, and the
|
|
481
|
+
// cache is keyed on tool+input alone. Caching here would mean an opt-out set
|
|
482
|
+
// before launch could never be observed being *un*set — the decision would
|
|
483
|
+
// outlive the input that produced it. Re-running the string check on each Bash
|
|
484
|
+
// call is cheaper than a cache that can contradict its own inputs.
|
|
485
|
+
if (this.dangerousRm(tool, input)) {
|
|
486
|
+
return 'ask'
|
|
487
|
+
}
|
|
488
|
+
|
|
466
489
|
// ── Cache lookup (P2): reuse decision for same tool+mode+input ──
|
|
467
490
|
const cacheKey = this.cacheKey(tool, input)
|
|
468
491
|
if (this.cacheMode === this.mode) {
|
|
@@ -551,7 +574,7 @@ export class PermissionSystem {
|
|
|
551
574
|
explainDenial(
|
|
552
575
|
tool: ToolDefinition,
|
|
553
576
|
input: Record<string, unknown>,
|
|
554
|
-
): { reason: PermissionDenialReason; rulePattern?: string } {
|
|
577
|
+
): { reason: PermissionDenialReason; rulePattern?: string; target?: string } {
|
|
555
578
|
for (const rule of this.denyRules) {
|
|
556
579
|
if (this.ruleMatches(rule, tool, input)) {
|
|
557
580
|
return { reason: 'deny-rule', rulePattern: rule.pattern }
|
|
@@ -562,6 +585,10 @@ export class PermissionSystem {
|
|
|
562
585
|
return { reason: 'ask-rule', rulePattern: rule.pattern }
|
|
563
586
|
}
|
|
564
587
|
}
|
|
588
|
+
const dangerous = this.dangerousRm(tool, input)
|
|
589
|
+
if (dangerous) {
|
|
590
|
+
return { reason: 'dangerous-rm', target: dangerous.target }
|
|
591
|
+
}
|
|
565
592
|
if (this.legacyRules.has(tool.name)) {
|
|
566
593
|
return { reason: 'legacy-rule' }
|
|
567
594
|
}
|
|
@@ -583,6 +610,28 @@ export class PermissionSystem {
|
|
|
583
610
|
return tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
|
|
584
611
|
}
|
|
585
612
|
|
|
613
|
+
/**
|
|
614
|
+
* A recursive `rm` whose target is not a path written in the command.
|
|
615
|
+
*
|
|
616
|
+
* Only the Bash tool: the question is about a *shell command line*, and a tool
|
|
617
|
+
* that merely happens to take a `command` parameter is not making a claim about
|
|
618
|
+
* what it will delete.
|
|
619
|
+
*
|
|
620
|
+
* The escape hatch is read from the **environment**, not from tool parameters.
|
|
621
|
+
* That is the difference between an operator switch and a model switch: a
|
|
622
|
+
* parameter the call can set is a guard the call can turn off, and
|
|
623
|
+
* `dangerouslyDisableSandbox` already shows how that ends. Read at call time
|
|
624
|
+
* rather than captured at construction so that a process which sets it before
|
|
625
|
+
* its first matching call gets the documented behaviour.
|
|
626
|
+
*/
|
|
627
|
+
private dangerousRm(tool: ToolDefinition, input: Record<string, unknown>): DangerousRm | null {
|
|
628
|
+
if (tool.name !== 'Bash') return null
|
|
629
|
+
if (process.env.MIPHAM_DISABLE_DANGEROUS_RM_PROMPT === '1') return null
|
|
630
|
+
const command = input.command
|
|
631
|
+
if (typeof command !== 'string') return null
|
|
632
|
+
return detectDangerousRm(command)
|
|
633
|
+
}
|
|
634
|
+
|
|
586
635
|
/**
|
|
587
636
|
* Resolve a call to a decision, consulting `auto` mode's classifier when — and
|
|
588
637
|
* only when — the static chain answered `'ask'` for a reason a classifier is
|
package/src/core/rule-engine.ts
CHANGED
|
@@ -18,6 +18,23 @@ export interface ToolRule {
|
|
|
18
18
|
enabled: boolean
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 一条**能真正生效**的规则必须自带 `match`/`fix` —— 而这两个是函数,落不了盘。
|
|
23
|
+
* 判据放在这里而不是内联,是为了让「载回来的规则必须是能用的规则」只有一处定义。
|
|
24
|
+
*/
|
|
25
|
+
function isUsableRule(candidate: unknown): candidate is ToolRule {
|
|
26
|
+
const r = candidate as Partial<ToolRule> | null
|
|
27
|
+
return (
|
|
28
|
+
!!r &&
|
|
29
|
+
typeof r === 'object' &&
|
|
30
|
+
typeof r.id === 'string' &&
|
|
31
|
+
r.id.length > 0 &&
|
|
32
|
+
typeof r.toolName === 'string' &&
|
|
33
|
+
typeof r.match === 'function' &&
|
|
34
|
+
typeof r.fix === 'function'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
const BUILTIN_RULES: ToolRule[] = [
|
|
22
39
|
{
|
|
23
40
|
id: 'rule-timeout-bash-heavy',
|
|
@@ -168,9 +185,17 @@ export class ExperienceRuleEngine {
|
|
|
168
185
|
load(): void {
|
|
169
186
|
if (!existsSync(this.storePath)) return
|
|
170
187
|
try {
|
|
171
|
-
const
|
|
188
|
+
const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'))
|
|
189
|
+
// 形状门 —— 这一格的门比别处严,因为**这个 store 载不动一条可用的规则**:
|
|
190
|
+
// `ToolRule.match`/`fix` 是函数,`JSON.stringify` 必然丢,所以 `persist()` 写出去的
|
|
191
|
+
// 形状里不可能带回来它们。旧读法把这种条目照收,`getActiveRules()` 再把它报成
|
|
192
|
+
// active(`/rules` 面板跟着说它是活的),而 `intercept()` 里 `rule.match` 不存在
|
|
193
|
+
// → 抛 → 被那个 `try/catch` 吞成**静默惰性**。列出来是活的、实际永不触发 ——
|
|
194
|
+
// 正是本仓库反复收的那种账。收不进就是收不进:不校验形状 = 报一份假的活跃清单。
|
|
195
|
+
if (!Array.isArray(parsed)) return
|
|
172
196
|
const reservedIds = new Set([...BUILTIN_RULES, ...MANAGED_RULES].map((r) => r.id))
|
|
173
|
-
for (const rule of
|
|
197
|
+
for (const rule of parsed) {
|
|
198
|
+
if (!isUsableRule(rule)) continue
|
|
174
199
|
// Reject if a builtin/managed rule with the same ID exists (source rules always win)
|
|
175
200
|
if (reservedIds.has(rule.id)) continue
|
|
176
201
|
this.rules.push(rule)
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Inspired by Anthropic's RLAIF (Reinforcement Learning from AI Feedback):
|
|
5
5
|
* instead of relying on human feedback loops, the AI critiques its own
|
|
6
|
-
* tool calls before execution. A fast model
|
|
7
|
-
*
|
|
6
|
+
* tool calls before execution. A fast model performs a lightweight safety &
|
|
7
|
+
* correctness check — but "lightweight" is about the *prompt*, not the clock:
|
|
8
|
+
* it is a full model round-trip, measured at a median ≈4s on the configured
|
|
9
|
+
* provider (see `DEFAULT_SELF_CRITIQUE_CONFIG.timeoutMs`), not the sub-200ms
|
|
10
|
+
* this comment used to claim.
|
|
8
11
|
*
|
|
9
12
|
* Architecture:
|
|
10
13
|
* Model generates tool call
|
|
@@ -58,7 +61,16 @@ export const DEFAULT_SELF_CRITIQUE_CONFIG: SelfCritiqueConfig = {
|
|
|
58
61
|
enabled: false, // Opt-in by default — user enables via /crsi critique on
|
|
59
62
|
threshold: 0.6,
|
|
60
63
|
targetTools: ['Bash', 'Write', 'Edit', 'Agent'],
|
|
61
|
-
|
|
64
|
+
// Measured, not chosen: 30 real critiques against the configured provider
|
|
65
|
+
// (`findFastestModel` → `deepseek-v4-flash`; default model is the slower
|
|
66
|
+
// `-pro`) took min 1.85s / median 3.95s / max 17.9s, and **28 of 30 exceeded
|
|
67
|
+
// 2s** — the value this replaced. At 2s the budget would have skipped 93% of
|
|
68
|
+
// critiques, and since `critique()` fails *open* (null ⇒ the tool runs
|
|
69
|
+
// unreviewed) that failure is silent: `/crsi critique` would look enabled and
|
|
70
|
+
// do nothing. 15s keeps 90% (27/30) while still bounding a gated tool call to
|
|
71
|
+
// well under the provider's 90s stream-idle backstop. One model, one day —
|
|
72
|
+
// re-measure if the critique model changes.
|
|
73
|
+
timeoutMs: 15_000,
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
// ── Prompt Templates ──
|
package/src/core/session-log.ts
CHANGED
|
@@ -118,6 +118,66 @@ export function deriveMessages(events: SessionEvent[]): Message[] {
|
|
|
118
118
|
|
|
119
119
|
const LOG_DIR = miphamHome('sessions')
|
|
120
120
|
|
|
121
|
+
/** 补上的那条结果的正文:说明事情本身,并给出下一步,而不是只报一个状态。 */
|
|
122
|
+
function interruptedCallNotice(): string {
|
|
123
|
+
return (
|
|
124
|
+
`This tool call was in flight when the session ended, so its outcome is unknown — ` +
|
|
125
|
+
`the result was never recorded.\n` +
|
|
126
|
+
`Do not assume it succeeded or failed: check the actual state (read the files, ` +
|
|
127
|
+
`re-run the command) and re-issue the call if it did not take effect.`
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 恢复会话时收尾:给日志里**没有结果**的调用补一条「结果未知」的 `tool/result`
|
|
133
|
+
* 事件,返回补了几条。
|
|
134
|
+
*
|
|
135
|
+
* 为什么非补不可:助手消息里挂着 `tool_calls` 而没有任何结果回应,OpenAI / DeepSeek
|
|
136
|
+
* 会整条请求拒收,Anthropic 还要求 `tool_result` 紧跟在那条 `tool_use` 之后。于是
|
|
137
|
+
* 「这次的调用没收尾」在用户那里表现成「恢复之后说的第一句话就报协议错」。
|
|
138
|
+
*
|
|
139
|
+
* 这个形状**是从盘上读来的,不是引擎写出来的**:`engine.ts` 先落调用消息、紧接着
|
|
140
|
+
* 落结果(同一同步块),而这批事件只在退出时整份刷盘 —— 跑到一半被杀根本留不下那条
|
|
141
|
+
* 调用。够得着的是**读侧**:`save()` 逐行追加,`open()` 把读不动的行静默丢掉(半截
|
|
142
|
+
* JSON 过不了 `JSON.parse`),于是写盘写到一半被打断时,末尾那条结果被丢、调用留在
|
|
143
|
+
* 盘上。修在恢复这一步,是因为读侧的入口只有这一个(`ContextManager.restoreLog`)。
|
|
144
|
+
*
|
|
145
|
+
* 为什么补成**事件**而不是往投影里塞一条消息:本仓库的不变量是「模型看得见的必须已
|
|
146
|
+
* 记录」(`assertModelVisible`),凭空出现的消息正好违反它;`messageToEvents` /
|
|
147
|
+
* `deriveMessages` 的字节级互逆也不能被动过。补事件两边都成立 —— 模型**看得见那次
|
|
148
|
+
* 调用**(它本来就在历史里),也知道**结果未知**,于是它先去查证,而不是当成没发生过、
|
|
149
|
+
* 也不是猜成功或失败。
|
|
150
|
+
*
|
|
151
|
+
* 幂等:已经有结果的 id 不会再补第二条。
|
|
152
|
+
*/
|
|
153
|
+
export function closeInterruptedToolCalls(log: SessionLog): number {
|
|
154
|
+
const events = log.events()
|
|
155
|
+
const answered = new Set<string>()
|
|
156
|
+
for (const e of events) {
|
|
157
|
+
if (e.type === 'tool/result') answered.add(e.id)
|
|
158
|
+
else if (e.type === 'user/message' && Array.isArray(e.message.content)) {
|
|
159
|
+
// 结果也可能整条嵌在 user/message 里(多块消息不拆事件),一样算「已回答」。
|
|
160
|
+
for (const b of e.message.content) {
|
|
161
|
+
if (b.type === 'tool_result') answered.add(b.tool_use_id)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pending: string[] = []
|
|
167
|
+
for (const e of events) {
|
|
168
|
+
if (e.type === 'tool/call' && !answered.has(e.id)) pending.push(e.id)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const at = Date.now()
|
|
172
|
+
for (const id of pending) {
|
|
173
|
+
// 失败结果在投影里被读成 `error || content`(见 `deriveMessages`),两个字段同写;
|
|
174
|
+
// 这一段与 `deleted-cwd` 那次是同一个教训。
|
|
175
|
+
const content = interruptedCallNotice()
|
|
176
|
+
log.append({ type: 'tool/result', at, id, result: { success: false, content, error: content } })
|
|
177
|
+
}
|
|
178
|
+
return pending.length
|
|
179
|
+
}
|
|
180
|
+
|
|
121
181
|
/** 一次性告警:日志路径不是普通文件(写不进去),每个进程只说一句。 */
|
|
122
182
|
let warnedNotAppendable = false
|
|
123
183
|
|
package/src/daemon/index.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type { Server } from 'bun'
|
|
|
13
13
|
import { DaemonDatabase } from './database'
|
|
14
14
|
import { SessionManager } from './session-manager'
|
|
15
15
|
import { AgentManager } from './agent-manager'
|
|
16
|
-
import { MessageBus } from './message-bus'
|
|
17
16
|
import { GoalManager } from './goal-manager'
|
|
18
17
|
import { ScheduleManager } from './schedule-manager'
|
|
19
18
|
import { createServer } from './server'
|
|
@@ -118,7 +117,7 @@ export function getPort(): number {
|
|
|
118
117
|
* 1. Ensures ~/.mipham exists (mode 0o700)
|
|
119
118
|
* 2. Loads or creates the auth token
|
|
120
119
|
* 3. Initializes the SQLite database and runs JSONL migration on first start
|
|
121
|
-
* 4. Creates a SessionManager
|
|
120
|
+
* 4. Creates a SessionManager and AgentManager
|
|
122
121
|
* 5. Starts the HTTP server on an available port
|
|
123
122
|
* 6. Writes PID and port files to disk
|
|
124
123
|
*
|
|
@@ -152,9 +151,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
152
151
|
const pool = new WorkerPool(db)
|
|
153
152
|
activePool = pool
|
|
154
153
|
|
|
155
|
-
// Create agent manager
|
|
154
|
+
// Create agent manager (Phase 3)
|
|
156
155
|
const agentManager = new AgentManager(db)
|
|
157
|
-
const messageBus = new MessageBus()
|
|
158
156
|
|
|
159
157
|
// Create goal manager and schedule manager (Phase 4)
|
|
160
158
|
const goalManager = new GoalManager(db)
|
|
@@ -236,7 +234,6 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
236
234
|
port,
|
|
237
235
|
hostname,
|
|
238
236
|
agentManager,
|
|
239
|
-
messageBus,
|
|
240
237
|
goalManager,
|
|
241
238
|
scheduleManager,
|
|
242
239
|
rateLimiter,
|
package/src/daemon/launch.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawn, type SpawnOptions } from 'node:child_process'
|
|
15
|
-
import { closeSync, mkdirSync, openSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
16
16
|
import { dirname, resolve } from 'node:path'
|
|
17
17
|
import { miphamHome } from '../core/paths.ts'
|
|
18
18
|
|
|
@@ -141,6 +141,13 @@ const READY_TIMEOUT_MS = 10_000
|
|
|
141
141
|
const POLL_INTERVAL_MS = 100
|
|
142
142
|
/** How long `restart` waits for the *old* daemon to go before refusing. */
|
|
143
143
|
const OLD_DAEMON_EXIT_TIMEOUT_MS = 10_000
|
|
144
|
+
/**
|
|
145
|
+
* Bound on the daemon log, enforced by `rotateLogIfLarge`. Two files at this size
|
|
146
|
+
* are invisible on any disk it will sit on, while the reader only ever wants a tail
|
|
147
|
+
* (`tailLog` takes the last 800 bytes) — so what the bound has to preserve is not
|
|
148
|
+
* volume but *the previous generation* of a start that keeps failing.
|
|
149
|
+
*/
|
|
150
|
+
export const MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
144
151
|
|
|
145
152
|
async function defaultGetStatus(): Promise<DaemonStatusLike | null> {
|
|
146
153
|
const { getDaemonStatus } = await import('./index')
|
|
@@ -157,6 +164,45 @@ function tailLog(logPath: string, maxBytes = 800): string {
|
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Bound the log the daemon is about to append to.
|
|
169
|
+
*
|
|
170
|
+
* Called before the child is spawned, which is the only moment nothing holds it:
|
|
171
|
+
* `startDetachedDaemon` returns early when a daemon is already up, so by here its
|
|
172
|
+
* pid file was gone. (A daemon that died without unlinking the pid file could still
|
|
173
|
+
* hold the old inode — its lines then land in the renamed file, which loses nothing
|
|
174
|
+
* and corrupts nothing.)
|
|
175
|
+
*
|
|
176
|
+
* **Rename, not truncate.** Either bounds the file, and the reader only wants a tail
|
|
177
|
+
* — but the case this exists for is a start that keeps failing, and there the
|
|
178
|
+
* previous generation *is* the evidence. Keeping exactly one bounds the sink at two
|
|
179
|
+
* files; `renameSync` overwrites the target, so that is also the oldest generation's
|
|
180
|
+
* cleanup. There is never a `.2`.
|
|
181
|
+
*
|
|
182
|
+
* Failing to bound is never fatal: a daemon that refuses to start because its log
|
|
183
|
+
* could not be rotated trades a slow hazard for an immediate one. And an unreadable
|
|
184
|
+
* size means the append below is about to fail loudly anyway, so `return` is not a
|
|
185
|
+
* silent degradation of anything that was working.
|
|
186
|
+
*/
|
|
187
|
+
export function rotateLogIfLarge(logPath: string, maxBytes: number = MAX_LOG_BYTES): void {
|
|
188
|
+
let size: number
|
|
189
|
+
try {
|
|
190
|
+
size = statSync(logPath).size
|
|
191
|
+
} catch {
|
|
192
|
+
return // no log yet (every first start)
|
|
193
|
+
}
|
|
194
|
+
if (size < maxBytes) return
|
|
195
|
+
try {
|
|
196
|
+
renameSync(logPath, `${logPath}.1`)
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// Printed rather than thrown, but never swallowed: a bound that switched itself
|
|
199
|
+
// off in silence is the defect class this whole file is about.
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`⚠️ daemon 日志轮转失败,本次仍按无上限追加: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
160
206
|
/**
|
|
161
207
|
* Start the daemon detached and *wait until it is actually up*.
|
|
162
208
|
*
|
|
@@ -177,6 +223,7 @@ export async function startDetachedDaemon(
|
|
|
177
223
|
|
|
178
224
|
const plan = planDaemonSpawn()
|
|
179
225
|
mkdirSync(dirname(plan.logPath), { recursive: true, mode: 0o700 })
|
|
226
|
+
rotateLogIfLarge(plan.logPath)
|
|
180
227
|
|
|
181
228
|
let spawnError: Error | null = null
|
|
182
229
|
let exitCode: number | null = null
|
|
@@ -217,9 +264,29 @@ export async function startDetachedDaemon(
|
|
|
217
264
|
}
|
|
218
265
|
}
|
|
219
266
|
}
|
|
267
|
+
// The deadline is a prediction, not a fact. Probe once more before acting on
|
|
268
|
+
// it: a child that became ready inside the last poll interval is up, and
|
|
269
|
+
// reporting failure for it — then killing it — would be this module's own
|
|
270
|
+
// "success with no daemon behind it" defect wearing the opposite sign.
|
|
271
|
+
const late = await getStatus()
|
|
272
|
+
if (late) return { ok: true, pid: late.pid, port: late.port }
|
|
273
|
+
|
|
274
|
+
// Reclaim the child. Leaving it running makes a failed start a half-truth the
|
|
275
|
+
// caller cannot act on: `getStatus()` is a pid file plus `kill(pid, 0)`, so the
|
|
276
|
+
// next `daemon start` finds the abandoned process and reports *success* with
|
|
277
|
+
// its pid. SIGKILL, not SIGTERM: this process is by definition not ready, so
|
|
278
|
+
// there is no session to drain — and a child that has already ignored the
|
|
279
|
+
// deadline is exactly the one that may ignore a polite signal too.
|
|
280
|
+
const reclaimed = child.kill('SIGKILL')
|
|
220
281
|
return {
|
|
221
282
|
ok: false,
|
|
222
|
-
reason:
|
|
283
|
+
reason:
|
|
284
|
+
`daemon did not become ready within ${opts.timeoutMs ?? READY_TIMEOUT_MS}ms` +
|
|
285
|
+
// Claim the kill only when it happened: `kill()` also returns false for a
|
|
286
|
+
// child that exited on its own between the probe and here, and that is not
|
|
287
|
+
// something to report as "reclaimed".
|
|
288
|
+
(reclaimed ? '; the child it spawned was killed so no daemon is left running' : '') +
|
|
289
|
+
` (log: ${plan.logPath})`,
|
|
223
290
|
}
|
|
224
291
|
}
|
|
225
292
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { Server, ServerWebSocket } from 'bun'
|
|
|
3
3
|
import type { DaemonDatabase } from './database'
|
|
4
4
|
import type { SessionManager } from './session-manager'
|
|
5
5
|
import type { AgentManager } from './agent-manager'
|
|
6
|
-
import type { MessageBus } from './message-bus'
|
|
7
6
|
import type { DaemonGoal, AgentKind } from './types'
|
|
8
7
|
import type { GoalManager } from './goal-manager'
|
|
9
8
|
import type { ScheduleManager } from './schedule-manager'
|
|
@@ -49,7 +48,6 @@ interface ServerConfig {
|
|
|
49
48
|
port: number
|
|
50
49
|
hostname: string
|
|
51
50
|
agentManager: AgentManager
|
|
52
|
-
messageBus: MessageBus
|
|
53
51
|
goalManager: GoalManager
|
|
54
52
|
scheduleManager: ScheduleManager
|
|
55
53
|
rateLimiter: RateLimiter
|
|
@@ -156,7 +154,6 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
156
154
|
port,
|
|
157
155
|
hostname,
|
|
158
156
|
agentManager,
|
|
159
|
-
messageBus,
|
|
160
157
|
goalManager,
|
|
161
158
|
scheduleManager,
|
|
162
159
|
rateLimiter,
|
|
@@ -192,15 +189,8 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
192
189
|
}
|
|
193
190
|
}
|
|
194
191
|
|
|
195
|
-
// ── Agent lifecycle → WebSocket broadcast
|
|
192
|
+
// ── Agent lifecycle → WebSocket broadcast ──
|
|
196
193
|
agentManager.onLifecycleEvent((event) => {
|
|
197
|
-
// Register / unregister in the message bus for broadcastToSession routing
|
|
198
|
-
if (event.type === 'created') {
|
|
199
|
-
messageBus.registerAgent(event.agent.sessionId, event.agent.id)
|
|
200
|
-
} else if (event.type === 'completed' || event.type === 'failed') {
|
|
201
|
-
messageBus.unregisterAgent(event.agent.id)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
194
|
// Broadcast lifecycle events to all WebSocket clients in the agent's session
|
|
205
195
|
broadcast(event.agent.sessionId, {
|
|
206
196
|
type: 'agent_lifecycle',
|
|
@@ -670,8 +660,23 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
670
660
|
return json({ ok: false, error: 'Agent not found' }, { status: 404 })
|
|
671
661
|
}
|
|
672
662
|
|
|
673
|
-
messageBus.send(
|
|
674
|
-
|
|
663
|
+
// 从前这里 `messageBus.send(...)` 后回 **202 + `{ok:true}`** —— 一张兑现不了的收条。
|
|
664
|
+
// daemon 侧**不存在 agent 执行循环**(`AgentManager` 纯持久化,`src/daemon/*.ts` 里
|
|
665
|
+
// `SubAgent`/`spawn` 零命中),所以进程内没有任何东西会去读这条消息;而真正在用的
|
|
666
|
+
// 那条总线(`src/agent/message-bus.ts`)由子代理 / workflow 用 `bg-…` 那套 id 投递,
|
|
667
|
+
// 与这里的 `agent-<uuid8>` **不同一个 id 空间**,改投它也找不到人。
|
|
668
|
+
// 202 在这里是有害的:调用方据此认为话已送达,于是不再重试、也不再报错。
|
|
669
|
+
// 报 501 并给出替代路径 —— 只说「不支持」会让调用方反复重试同一件事。
|
|
670
|
+
return json(
|
|
671
|
+
{
|
|
672
|
+
ok: false,
|
|
673
|
+
error:
|
|
674
|
+
'Agent messaging is not implemented on the daemon: this build has no agent ' +
|
|
675
|
+
'execution loop, so nothing would receive the message. Use ' +
|
|
676
|
+
'POST /api/v1/sessions/:id/prompt to send work to a session instead.',
|
|
677
|
+
},
|
|
678
|
+
{ status: 501 },
|
|
679
|
+
)
|
|
675
680
|
}
|
|
676
681
|
|
|
677
682
|
// ── Goals (Phase 4 — service-backed) ────────────
|
|
@@ -806,7 +811,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
806
811
|
{
|
|
807
812
|
method: 'POST',
|
|
808
813
|
path: '/api/v1/agents/:id/message',
|
|
809
|
-
description: '
|
|
814
|
+
description: 'Not implemented — always 501; use POST /api/v1/sessions/:id/prompt',
|
|
810
815
|
},
|
|
811
816
|
{ method: 'GET', path: '/api/v1/goals', description: 'List goals for a session' },
|
|
812
817
|
{ method: 'POST', path: '/api/v1/goals', description: 'Create a goal' },
|