@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.
Files changed (52) hide show
  1. package/bin/mipham.ts +48 -7
  2. package/package.json +2 -2
  3. package/src/agent/agent-context.ts +8 -1
  4. package/src/agent/effectiveness-tracker.ts +16 -2
  5. package/src/agent/sub-agent.ts +25 -17
  6. package/src/agent-view/agents-standalone.tsx +42 -0
  7. package/src/core/autocomplete.ts +30 -2
  8. package/src/core/context.ts +61 -6
  9. package/src/core/dream-engine.ts +17 -2
  10. package/src/core/engine.ts +95 -34
  11. package/src/core/error-signature-db.ts +7 -2
  12. package/src/core/hooks-executor.ts +88 -11
  13. package/src/core/hooks.ts +26 -2
  14. package/src/core/instructions.ts +105 -17
  15. package/src/core/memory/memory-manager.ts +14 -6
  16. package/src/core/permission-classifier.ts +17 -5
  17. package/src/core/permission-rules.ts +1 -1
  18. package/src/core/permission.ts +50 -1
  19. package/src/core/rule-engine.ts +27 -2
  20. package/src/core/self-critique.ts +15 -3
  21. package/src/core/session-log.ts +60 -0
  22. package/src/daemon/index.ts +2 -5
  23. package/src/daemon/launch.ts +69 -2
  24. package/src/daemon/server.ts +19 -14
  25. package/src/i18n-core/locales/en-US.json +86 -143
  26. package/src/i18n-core/locales/zh-CN.json +86 -143
  27. package/src/index.tsx +17 -7
  28. package/src/mcp/client.ts +61 -0
  29. package/src/mcp/instructions.ts +49 -0
  30. package/src/mcp/types.ts +7 -0
  31. package/src/plugin/claude-plugin.ts +12 -2
  32. package/src/plugin/plugin-loader.ts +32 -14
  33. package/src/plugin/plugin-manager.ts +16 -2
  34. package/src/plugin/plugin-validator.ts +183 -1
  35. package/src/providers/anthropic.ts +159 -124
  36. package/src/providers/fetch-utils.ts +65 -34
  37. package/src/providers/openai-compat.ts +121 -106
  38. package/src/security/dangerous-rm.ts +192 -0
  39. package/src/shared/arg-validation.ts +11 -1
  40. package/src/shared/constants.ts +18 -0
  41. package/src/shared/deleted-cwd.ts +46 -1
  42. package/src/shared/package-info.ts +36 -1
  43. package/src/shared/types.ts +8 -0
  44. package/src/shared/update.ts +290 -146
  45. package/src/ui/command-picker.tsx +18 -10
  46. package/src/ui/commands.ts +20 -6
  47. package/src/ui/config-wizard.tsx +22 -19
  48. package/src/ui/graft-status.tsx +35 -6
  49. package/src/ui/input.tsx +7 -2
  50. package/src/ui/picker.tsx +38 -27
  51. package/src/ui/use-key-state.ts +55 -0
  52. 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. Same bound as
61
- * `self-critique.ts:52`, which is the only measured precedent in this repo.
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
- * nobody has measured where the real latency distribution sits. Making it
68
- * configurable is the right fix when someone does.
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)
@@ -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
@@ -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 raw = JSON.parse(readFileSync(this.storePath, 'utf-8')) as ToolRule[]
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 raw) {
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 (Flash / Qwen2.5-1.5B) performs
7
- * a lightweight safety & correctness check with <200ms latency.
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
- timeoutMs: 2000,
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 ──
@@ -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
 
@@ -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, AgentManager, and MessageBus
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 and message bus (Phase 3)
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,
@@ -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: `daemon did not become ready within ${opts.timeoutMs ?? READY_TIMEOUT_MS}ms (log: ${plan.logPath})`,
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
 
@@ -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 + MessageBus registration ──
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('user', agentId, content)
674
- return json({ ok: true }, { status: 202 })
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: 'Send message to an agent',
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' },