@miphamai/cli 0.85.1 → 0.85.3

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 (63) hide show
  1. package/bin/mipham.ts +42 -2
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +9 -2
  4. package/src/agent/agent-experience.ts +3 -2
  5. package/src/agent/background-registry.ts +7 -3
  6. package/src/agent/cross-session/discovery.ts +5 -12
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +3 -2
  9. package/src/agent/sub-agent.ts +67 -6
  10. package/src/agent/types.ts +10 -0
  11. package/src/agent-view/agent-view-manager.ts +46 -0
  12. package/src/agent-view/dashboard.tsx +106 -16
  13. package/src/agent-view/session-view.tsx +128 -0
  14. package/src/commands/autoloop-journal.ts +6 -5
  15. package/src/commands/environment.ts +11 -8
  16. package/src/commands/project.ts +5 -3
  17. package/src/config/credential-crypto.ts +13 -1
  18. package/src/config/keys-manager.ts +7 -4
  19. package/src/config/loader.ts +144 -41
  20. package/src/config/preferences.ts +6 -3
  21. package/src/core/constitution-loader.ts +3 -2
  22. package/src/core/context.ts +35 -6
  23. package/src/core/crsi-producer.ts +4 -2
  24. package/src/core/crsi-sandbox.ts +2 -1
  25. package/src/core/dream-engine.ts +5 -11
  26. package/src/core/engine.ts +9 -4
  27. package/src/core/error-signature-db.ts +3 -2
  28. package/src/core/eval-harness.ts +3 -3
  29. package/src/core/hooks-executor.ts +60 -2
  30. package/src/core/instructions.ts +69 -48
  31. package/src/core/memory/memory-manager.ts +10 -6
  32. package/src/core/permission-audit.ts +13 -6
  33. package/src/core/permission-config.ts +28 -7
  34. package/src/core/permission-rules.ts +99 -5
  35. package/src/core/permission.ts +6 -1
  36. package/src/core/rule-engine.ts +3 -2
  37. package/src/core/session-log.ts +48 -11
  38. package/src/core/session-store.ts +64 -44
  39. package/src/daemon/attach-protocol.ts +30 -3
  40. package/src/daemon/auth.ts +4 -3
  41. package/src/daemon/index.ts +4 -3
  42. package/src/daemon/remote-engine.ts +173 -24
  43. package/src/daemon/server.ts +51 -1
  44. package/src/daemon/session-worker.ts +34 -1
  45. package/src/i18n-core/locales/en-US.json +1 -0
  46. package/src/i18n-core/locales/zh-CN.json +1 -0
  47. package/src/index.tsx +75 -17
  48. package/src/mcp/oauth.ts +47 -5
  49. package/src/mcp/token-store.ts +10 -11
  50. package/src/providers/openai-compat.ts +11 -0
  51. package/src/shared/arg-validation.ts +74 -2
  52. package/src/shared/package-info.ts +1 -1
  53. package/src/shared/regular-file.ts +63 -0
  54. package/src/shared/sanitize.ts +14 -2
  55. package/src/skills/bundled-skills.ts +1 -1
  56. package/src/tools/agent/memory.ts +4 -2
  57. package/src/tools/agent/workflow.ts +6 -3
  58. package/src/tools/exec/task.ts +82 -30
  59. package/src/tools/scheduling/cron.ts +3 -9
  60. package/src/ui/app.tsx +78 -14
  61. package/src/ui/commands.ts +73 -31
  62. package/src/ui/ctrl-c-confirm.ts +63 -0
  63. package/src/workflow/journal.ts +80 -26
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import type { BackgroundAgentRegistry, BackgroundTask } from '../../agent/background-registry'
2
3
  import { hasSupportedEvidenceSince } from '../../core/working-memory'
3
4
 
4
5
  export interface Task {
@@ -14,9 +15,7 @@ export interface Task {
14
15
  createdAt: string
15
16
  /** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
16
17
  completionEvidence?: 'supported' | 'unverified'
17
- /** Background task output content (set when a background agent completes). */
18
- output?: string
19
- /** Background task error message (set when a background agent fails). */
18
+ /** Set by the `stop` action on a running task ("Task stopped by user."). */
20
19
  outputError?: string
21
20
  }
22
21
 
@@ -36,6 +35,66 @@ function formatTask(t: Task): string {
36
35
  return `[${t.status}] #${t.id}: ${t.subject}${owner}${blocks}${blocked}${meta}`
37
36
  }
38
37
 
38
+ /**
39
+ * Render a **background agent** the way the `output` action renders a session task.
40
+ *
41
+ * Background agents are not in the local `tasks` map: BackgroundAgentRegistry mints
42
+ * their ids in a separate space (`bg-<n>-<base36>`) and keeps results on the registry
43
+ * itself. The Agent tool hands those ids to the model with an explicit
44
+ * `Use Task output taskId="bg-…"` line, so this action has to answer for them —
45
+ * without this path the advertised call can only return `Task #bg-… not found`.
46
+ */
47
+ function formatBackgroundOutput(bg: BackgroundTask): {
48
+ success: boolean
49
+ content: string
50
+ error?: string
51
+ } {
52
+ const head = `Background task ${bg.id}`
53
+ const meta = `Status: ${bg.status}\nAgent type: ${bg.agentType}\nTask: ${bg.description}`
54
+
55
+ if (bg.status === 'running') {
56
+ return {
57
+ success: true,
58
+ content: `${head} is still running — output not yet available.\n${meta}\n\nUse Task output again once it completes.`,
59
+ }
60
+ }
61
+
62
+ if (bg.status === 'failed') {
63
+ return {
64
+ success: false,
65
+ content: '',
66
+ error: `${head} failed: ${bg.error ?? '(no error recorded)'}`,
67
+ }
68
+ }
69
+
70
+ return {
71
+ success: true,
72
+ content: `── ${head} Output ──\n${meta}\n\n${(bg.result ?? '(no output recorded)').slice(0, 5000)}`,
73
+ }
74
+ }
75
+
76
+ /** Stop a running background agent through the registry that owns it. */
77
+ function stopBackgroundTask(
78
+ bg: BackgroundTask,
79
+ registry: BackgroundAgentRegistry,
80
+ ): { success: boolean; content: string; error?: string } {
81
+ if (bg.status !== 'running') {
82
+ return {
83
+ success: true,
84
+ content: `Background task ${bg.id} is already ${bg.status}. Nothing to stop.`,
85
+ }
86
+ }
87
+ // `stop()` also returns false when the task is no longer running (it completed
88
+ // between the lookup above and this call), so the message must not claim why.
89
+ const aborted = registry.stop(bg.id)
90
+ return {
91
+ success: true,
92
+ content: aborted
93
+ ? `Background task ${bg.id} stopped.\nTask: ${bg.description}`
94
+ : `Background task ${bg.id} was not stopped — it is no longer running.`,
95
+ }
96
+ }
97
+
39
98
  /** Check if a task is blocked — has unresolved dependencies. */
40
99
  function isBlocked(task: Task): boolean {
41
100
  if (task.blockedBy.length === 0) return false
@@ -238,11 +297,6 @@ export const taskTool: ToolDefinition = {
238
297
  }
239
298
  }
240
299
  if (task.metadata) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`)
241
- if (task.output) {
242
- lines.push('')
243
- lines.push('── Output ──')
244
- lines.push(task.output.slice(0, 2000))
245
- }
246
300
  if (task.outputError) {
247
301
  lines.push('')
248
302
  lines.push(`── Error ──`)
@@ -320,7 +374,14 @@ export const taskTool: ToolDefinition = {
320
374
  if (action === 'output') {
321
375
  const taskId = params.taskId as string
322
376
  const task = tasks.get(taskId)
323
- if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
377
+ if (!task) {
378
+ // Not a session task — it may be a background agent id (`bg-…`), which is
379
+ // the id space this action is advertised for. The registry is wired onto
380
+ // the tool context by the engine (`engine.ts` defaultToolContext).
381
+ const bg = _ctx.backgroundAgentRegistry?.get(taskId)
382
+ if (bg) return formatBackgroundOutput(bg)
383
+ return { success: false, content: '', error: `Task #${taskId} not found` }
384
+ }
324
385
 
325
386
  if (task.status === 'pending') {
326
387
  return {
@@ -336,13 +397,6 @@ export const taskTool: ToolDefinition = {
336
397
  }
337
398
  }
338
399
 
339
- if (task.status === 'completed' && task.output) {
340
- return {
341
- success: true,
342
- content: `── Task #${taskId} Output ──\nStatus: completed\nSubject: ${task.subject}\n\n${task.output.slice(0, 5000)}`,
343
- }
344
- }
345
-
346
400
  if (task.status === 'failed' && task.outputError) {
347
401
  return {
348
402
  success: false,
@@ -361,7 +415,15 @@ export const taskTool: ToolDefinition = {
361
415
  if (action === 'stop') {
362
416
  const taskId = params.taskId as string
363
417
  const task = tasks.get(taskId)
364
- if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
418
+ if (!task) {
419
+ // Background agent id (`bg-…`) — the other id space. The local branch
420
+ // below only marks a status; an actual running agent is aborted through
421
+ // the registry that owns it.
422
+ const registry = _ctx.backgroundAgentRegistry
423
+ const bg = registry?.get(taskId)
424
+ if (registry && bg) return stopBackgroundTask(bg, registry)
425
+ return { success: false, content: '', error: `Task #${taskId} not found` }
426
+ }
365
427
 
366
428
  if (task.status === 'completed' || task.status === 'deleted') {
367
429
  return {
@@ -376,22 +438,12 @@ export const taskTool: ToolDefinition = {
376
438
  return { success: true, content: `Task #${taskId} cancelled (was pending).` }
377
439
  }
378
440
 
379
- // For in_progress tasks: mark as failed — actual process termination is handled
380
- // by BackgroundAgentRegistry.stop() when wired via the engine context
441
+ // For in_progress session tasks: mark as failed. There is no process behind
442
+ // one — a *background agent* is aborted by stopBackgroundTask() above, which
443
+ // the registry owns.
381
444
  task.status = 'failed'
382
445
  task.outputError = 'Task stopped by user.'
383
446
 
384
- // If BackgroundAgentRegistry is available in context, try to abort
385
- if (_ctx.registry) {
386
- // Signal to any background registry that this task should stop
387
- // The background registry will be injected via the engine context
388
- const bgRegistry = (_ctx as unknown as Record<string, unknown>).backgroundAgentRegistry as
389
- { stop(id: string): boolean } | undefined
390
- if (bgRegistry) {
391
- bgRegistry.stop(taskId)
392
- }
393
- }
394
-
395
447
  return { success: true, content: `Task #${taskId} stopped.` }
396
448
  }
397
449
 
@@ -1,11 +1,5 @@
1
- import {
2
- mkdirSync,
3
- readFileSync,
4
- writeFileSync,
5
- unlinkSync,
6
- readdirSync,
7
- existsSync,
8
- } from 'node:fs'
1
+ import { mkdirSync, readFileSync, unlinkSync, readdirSync, existsSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
9
3
  import { join } from 'node:path'
10
4
  import { createHash } from 'node:crypto'
11
5
  import type { ToolDefinition } from '../../shared/index.ts'
@@ -78,7 +72,7 @@ export function readAllJobs(): CronJob[] {
78
72
  /** Persist a job (create or update) to its JSON file. */
79
73
  export function writeJob(job: CronJob): void {
80
74
  ensureCronDir()
81
- writeFileSync(jobPath(job.id), JSON.stringify(job, null, 2), 'utf-8')
75
+ atomicWriteFileSync(jobPath(job.id), JSON.stringify(job, null, 2), { mode: 0o644 })
82
76
  }
83
77
 
84
78
  /** Delete a job's file. Returns false when the job did not exist. */
package/src/ui/app.tsx CHANGED
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
4
  import { execSync } from 'node:child_process'
5
5
  import { ErrorBoundary } from './error-boundary'
6
+ import { useCtrlCConfirm } from './ctrl-c-confirm'
6
7
  import { formatThinking } from './thinking'
7
8
  import type { QueryEngine } from '../core/engine'
8
9
  import type { RemoteEngine } from '../daemon/remote-engine'
@@ -45,6 +46,7 @@ function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined
45
46
  return Math.round((ctx.getEstimatedTokens() / max) * 100)
46
47
  }
47
48
  import { AgentViewDashboard } from '../agent-view/dashboard'
49
+ import { AgentSessionView } from '../agent-view/session-view'
48
50
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
49
51
  import { WorkflowProgress } from './workflow-progress.js'
50
52
  import { GoalProgress } from './goal-progress.js'
@@ -317,6 +319,11 @@ export function App({
317
319
  }, [])
318
320
 
319
321
  const [agentViewOpen, setAgentViewOpen] = useState(false)
322
+ // Non-null when the dashboard's Enter ("attach") picked a session to read.
323
+ // It replaces the chat panel — the transcript on screen is the session's, not
324
+ // this conversation's, so leaving the input bar visible would invite typing
325
+ // into a buffer that goes somewhere else entirely.
326
+ const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null)
320
327
  const [apiKeyPrompt, setApiKeyPrompt] = useState<{
321
328
  providerId: string
322
329
  modelId: string
@@ -334,6 +341,14 @@ export function App({
334
341
  const [permissionMode, setPermissionMode] = useState<PermissionMode>(() =>
335
342
  livePermissionMode(engine.getPermission()),
336
343
  )
344
+ // 远端 attach:闸门在 daemon 上,而它的答案是**异步**来的(组织级限制会在那边把请求
345
+ // 静默钳小)。按键不等于渲染,所以只靠上行的 `useState` 初始化 + 按键回读,页脚会永远
346
+ // 停在钳制前的那一档 —— 报得比实际宽。本地引擎没有这条订阅:它的 `getMode()` 在
347
+ // `setMode` 返回时就已经是钳后值。
348
+ useEffect(() => {
349
+ if (!('onPermissionModeChange' in engine)) return
350
+ return engine.onPermissionModeChange((mode) => setPermissionMode(mode))
351
+ }, [engine])
337
352
  const abortRef = useRef<AbortController | null>(null)
338
353
  // Monotonic turn id — lets a stale turn's finally() skip resetting shared UI
339
354
  // state (isLoading/abortRef/progress) after a newer turn has already started.
@@ -1136,22 +1151,55 @@ export function App({
1136
1151
  [engine, mkCtx, runTurn],
1137
1152
  )
1138
1153
 
1139
- useInput((_input, key) => {
1140
- // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
1141
- if (key.escape) {
1142
- if (apiKeyPrompt) {
1143
- setApiKeyPrompt(null)
1144
- setApiKeyInput('')
1145
- return
1146
- }
1147
- if (pickerOpen) {
1148
- setPickerOpen(false)
1154
+ /**
1155
+ * 关掉「最上面那一层」—— 密钥提示框 / 选择器 / 正在跑的回合。关掉了返回 true。
1156
+ *
1157
+ * Esc 与 Ctrl+C 共用这一份:在用户心里这两个键是同一件事(「停」),分成两套
1158
+ * 判断迟早会漂移。没有可关的东西 ⇒ false,由调用方决定下一步。
1159
+ */
1160
+ const dismissTopmost = (): boolean => {
1161
+ if (apiKeyPrompt) {
1162
+ setApiKeyPrompt(null)
1163
+ setApiKeyInput('')
1164
+ return true
1165
+ }
1166
+ if (pickerOpen) {
1167
+ setPickerOpen(false)
1168
+ return true
1169
+ }
1170
+ if (isLoading && abortRef.current) {
1171
+ abortRef.current.abort()
1172
+ return true
1173
+ }
1174
+ return false
1175
+ }
1176
+
1177
+ // Ctrl+C 的「再按一次才退」,语义与计时器见 ui/ctrl-c-confirm.ts
1178
+ const ctrlC = useCtrlCConfirm()
1179
+
1180
+ useInput((input, key) => {
1181
+ // Ctrl+C:先关最上面那一层;没得关时**不退出**,只提示「再按一次」。
1182
+ //
1183
+ // 从前这里没有这个分支,而 Ink 自己的 Ctrl+C 处理(`exitOnCtrlC`,默认
1184
+ // true)在按键**到达任何 handler 之前**就把进程退了 —— 于是对话框里一次
1185
+ // 误按就把整个会话带走。`render()` 三处均已传 `exitOnCtrlC: false`,
1186
+ // 否则这个分支永远不会被调用(见 src/index.tsx 的注释)。
1187
+ if (key.ctrl && input === 'c') {
1188
+ if (dismissTopmost()) {
1189
+ // 这一按被「关掉最上面一层」消费掉了 —— 用户想的是「停」,不是「退」,
1190
+ // 所以待确认窗口要一并撤掉,免得下一次误按直接退出。
1191
+ ctrlC.reset()
1149
1192
  return
1150
1193
  }
1151
- if (isLoading && abortRef.current) {
1152
- abortRef.current.abort()
1153
- return
1194
+ if (ctrlC.isArmed()) {
1195
+ process.exit(0)
1154
1196
  }
1197
+ ctrlC.arm()
1198
+ return
1199
+ }
1200
+ // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
1201
+ if (key.escape) {
1202
+ if (dismissTopmost()) return
1155
1203
  // Otherwise let InputBar handle Escape (clear draft)
1156
1204
  return
1157
1205
  }
@@ -1230,9 +1278,24 @@ export function App({
1230
1278
  {agentViewOpen && agentViewManager ? (
1231
1279
  <AgentViewDashboard
1232
1280
  manager={agentViewManager}
1233
- onAttach={() => {}}
1281
+ // Enter used to be wired to a no-op here, so `attach` — the key the
1282
+ // footer advertises — did nothing observable. It now hands the
1283
+ // session to the read-only view below; Esc there comes back here.
1284
+ onAttach={(session) => {
1285
+ setAttachedSessionId(session.id)
1286
+ setAgentViewOpen(false)
1287
+ }}
1234
1288
  onExit={() => setAgentViewOpen(false)}
1235
1289
  />
1290
+ ) : attachedSessionId && agentViewManager ? (
1291
+ <AgentSessionView
1292
+ manager={agentViewManager}
1293
+ sessionId={attachedSessionId}
1294
+ onDetach={() => {
1295
+ setAttachedSessionId(null)
1296
+ setAgentViewOpen(true)
1297
+ }}
1298
+ />
1236
1299
  ) : (
1237
1300
  <>
1238
1301
  {/* Chat panel */}
@@ -1372,6 +1435,7 @@ export function App({
1372
1435
  {' · '}
1373
1436
  {t('ui.status.left_for_agents')}
1374
1437
  </Text>
1438
+ {ctrlC.armed && <Text color="yellow"> {t('ui.status.ctrl_c_again')}</Text>}
1375
1439
  </Box>
1376
1440
  </Box>
1377
1441
 
@@ -389,6 +389,16 @@ const contextCmd: CommandHandler = (ctx) => {
389
389
  }
390
390
  }
391
391
 
392
+ /**
393
+ * The mode the gate is actually enforcing. `ctx.config.permission` is only one of
394
+ * the doors — the Shift+Tab wheel, an org `maxAllowedMode` clamp and the user's
395
+ * `settings.json` all move the live value — so a report that prints the config
396
+ * value can name a different mode than the one refusing the calls.
397
+ */
398
+ function liveMode(ctx: CommandContext): string {
399
+ return ctx.engine.getPermission().getMode()
400
+ }
401
+
392
402
  const statusCmd: CommandHandler = async (ctx) => {
393
403
  const t = resolveT(ctx)
394
404
  const c = ctx.engine.getContext()
@@ -411,7 +421,7 @@ const statusCmd: CommandHandler = async (ctx) => {
411
421
  ${t('commands.status.messages')} ${c.getMessages().length}
412
422
  ${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / ${c.getMaxTokens().toLocaleString()}
413
423
  ${t('commands.status.tools')} ${tools.size} ${t('commands.status.loaded')}
414
- ${t('commands.status.permission')} ${ctx.config.permission}
424
+ ${t('commands.status.permission')} ${liveMode(ctx)}
415
425
 
416
426
  ${t('commands.status.system_title')}
417
427
  ${t('commands.status.platform')} ${process.platform} ${process.arch}
@@ -2504,7 +2514,8 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
2504
2514
  isValidMarketplaceRef,
2505
2515
  MARKETPLACES_PATH,
2506
2516
  } = await import('../skills/marketplace')
2507
- const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs')
2517
+ const { readFileSync, existsSync, mkdirSync } = await import('node:fs')
2518
+ const { atomicWriteFileSync } = await import('../shared/atomic-write')
2508
2519
  const { dirname } = await import('node:path')
2509
2520
 
2510
2521
  const readSources = () =>
@@ -2537,7 +2548,9 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
2537
2548
  const changed = 'added' in result ? result.added : result.removed
2538
2549
  try {
2539
2550
  mkdirSync(dirname(MARKETPLACES_PATH), { recursive: true })
2540
- writeFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), 'utf-8')
2551
+ atomicWriteFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), {
2552
+ mode: 0o644,
2553
+ })
2541
2554
  } catch (err) {
2542
2555
  return { content: `❌ Failed to save marketplaces: ${String(err)}` }
2543
2556
  }
@@ -3208,7 +3221,7 @@ const doctorCmd: CommandHandler = async (ctx) => {
3208
3221
  '',
3209
3222
  t('commands.doctor.config_section'),
3210
3223
  `Provider ${ctx.providerId} / ${ctx.modelId}`,
3211
- `Permission ${ctx.config.permission}`,
3224
+ `Permission ${liveMode(ctx)}`,
3212
3225
  `Providers ${ctx.config.providers.length} configured (${ctx.config.providers.filter((p) => p.status !== 'upcoming').length} active)`,
3213
3226
  '',
3214
3227
  t('commands.doctor.session_section'),
@@ -3724,7 +3737,7 @@ const statsCmd: CommandHandler = (ctx) => {
3724
3737
  t('commands.stats.tools', { count: String(tools.size) }),
3725
3738
  t('commands.stats.provider', { provider: ctx.providerId }),
3726
3739
  t('commands.stats.model', { model: ctx.modelId }),
3727
- t('commands.stats.permission', { permission: ctx.config.permission }),
3740
+ t('commands.stats.permission', { permission: liveMode(ctx) }),
3728
3741
  '',
3729
3742
  t('commands.stats.usage', { pct: ((tokens / c.getMaxTokens()) * 100).toFixed(1) }),
3730
3743
  ]
@@ -5197,10 +5210,14 @@ Background agents appear in the Agent View dashboard (/agents).`,
5197
5210
  // wrote a dashboard entry and reported success — the prompt was never handed
5198
5211
  // to a model, and the row sat at `working` forever.
5199
5212
  const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
5200
- bgReg.spawn(
5213
+ // Keep the registry's id on the row: it is the only handle that can stop the
5214
+ // run. `AgentViewManager.kill()` flips a status label and nothing else, so a
5215
+ // Ctrl+X that held no task id would discard the row and leave the agent
5216
+ // running with no way to reach it again.
5217
+ session.taskId = bgReg.spawn(
5201
5218
  prompt,
5202
5219
  'general',
5203
- async (_signal) => {
5220
+ async (signal) => {
5204
5221
  const { SubAgent } = await import('../agent/sub-agent')
5205
5222
  const sa = new SubAgent(
5206
5223
  ctx.engine.getRegistry(),
@@ -5213,7 +5230,10 @@ Background agents appear in the Agent View dashboard (/agents).`,
5213
5230
  try {
5214
5231
  // The prompt came from the user typing `/bg …`, so it stays unframed —
5215
5232
  // unlike a workflow script's computed prompt.
5216
- const result = await sa.execute(prompt, 'bg: ' + prompt.slice(0, 60), { type: 'general' })
5233
+ const result = await sa.execute(prompt, 'bg: ' + prompt.slice(0, 60), {
5234
+ type: 'general',
5235
+ signal,
5236
+ })
5217
5237
  agentViewManager.addMessage(session.id, {
5218
5238
  role: 'assistant',
5219
5239
  content: result || '(no output)',
@@ -5326,10 +5346,10 @@ const forkCmd: CommandHandler = async (ctx, args) => {
5326
5346
  session.kind = 'forked'
5327
5347
 
5328
5348
  const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
5329
- bgReg.spawn(
5349
+ session.taskId = bgReg.spawn(
5330
5350
  prompt,
5331
5351
  'general',
5332
- async (_signal) => {
5352
+ async (signal) => {
5333
5353
  const { SubAgent } = await import('../agent/sub-agent')
5334
5354
  const sa = new SubAgent(
5335
5355
  ctx.engine.getRegistry(),
@@ -5340,32 +5360,54 @@ const forkCmd: CommandHandler = async (ctx, args) => {
5340
5360
  ctx.engine.getLlm(),
5341
5361
  )
5342
5362
  const parentContext = ctx.engine.getContext()
5343
- const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
5344
- worktreePath: wtPath,
5345
- inheritContext: { messages: parentContext.getMessages() },
5346
- })
5363
+ // The row has to come back from `working` on both outcomes: the fork
5364
+ // executor used to return its summary to the registry and never touch the
5365
+ // session, so a finished (or crashed) fork stayed `working` on the
5366
+ // dashboard for the life of the process — the same lie `/bg` already
5367
+ // closes on its own path.
5347
5368
  try {
5348
- const { execSync: ex } = await import('node:child_process')
5349
- ex(`git -C ${wtPath} add -A`, { stdio: 'ignore', timeout: 10_000 })
5350
- const st = ex(`git -C ${wtPath} status --porcelain`, { encoding: 'utf-8', timeout: 10_000 })
5351
- if (st.trim()) {
5352
- ex(`git -C ${wtPath} commit -m "feat: ${prompt.slice(0, 60)}\n\n${COAUTHOR_TRAILER}"`, {
5353
- stdio: 'ignore',
5369
+ const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
5370
+ worktreePath: wtPath,
5371
+ inheritContext: { messages: parentContext.getMessages() },
5372
+ signal,
5373
+ })
5374
+ try {
5375
+ const { execSync: ex } = await import('node:child_process')
5376
+ ex(`git -C ${wtPath} add -A`, { stdio: 'ignore', timeout: 10_000 })
5377
+ const st = ex(`git -C ${wtPath} status --porcelain`, {
5378
+ encoding: 'utf-8',
5354
5379
  timeout: 10_000,
5355
5380
  })
5356
- ex(`git push origin ${branch}`, { stdio: 'ignore', timeout: 30_000 })
5381
+ if (st.trim()) {
5382
+ ex(`git -C ${wtPath} commit -m "feat: ${prompt.slice(0, 60)}\n\n${COAUTHOR_TRAILER}"`, {
5383
+ stdio: 'ignore',
5384
+ timeout: 10_000,
5385
+ })
5386
+ ex(`git push origin ${branch}`, { stdio: 'ignore', timeout: 30_000 })
5387
+ }
5388
+ } catch {
5389
+ /* best-effort */
5357
5390
  }
5358
- } catch {
5359
- /* best-effort */
5391
+ const summary = [
5392
+ `## Fork done: ${prompt}`,
5393
+ '',
5394
+ `Branch: \`${branch}\``,
5395
+ `Worktree: \`${wtPath}\``,
5396
+ '',
5397
+ result || '(no output)',
5398
+ ].join('\n')
5399
+ agentViewManager.addMessage(session.id, { role: 'assistant', content: summary })
5400
+ agentViewManager.updateStatus(session.id, 'completed')
5401
+ return summary
5402
+ } catch (err) {
5403
+ const message = err instanceof Error ? err.message : String(err)
5404
+ agentViewManager.addMessage(session.id, {
5405
+ role: 'assistant',
5406
+ content: `Fork failed: ${message}`,
5407
+ })
5408
+ agentViewManager.updateStatus(session.id, 'failed')
5409
+ throw err
5360
5410
  }
5361
- return [
5362
- `## Fork done: ${prompt}`,
5363
- '',
5364
- `Branch: \`${branch}\``,
5365
- `Worktree: \`${wtPath}\``,
5366
- '',
5367
- result || '(no output)',
5368
- ].join('\n')
5369
5411
  },
5370
5412
  'forked',
5371
5413
  )
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Ctrl+C 的「再按一次才退出」窗口。
5
+ *
6
+ * 为什么需要它:Ink 默认的 Ctrl+C 语义是**立刻退出**,而 TUI 里 Ctrl+C 的合
7
+ * 理语义是「停下手上这件事」—— 对话框开着、回合跑着的时候,误按一次不该把
8
+ * 整个会话带走。所以 `render()` 处一律传 `exitOnCtrlC: false`(见
9
+ * `src/index.tsx`),退出的决定权收回到这里:第一次按只「上膛」,窗口内再按
10
+ * 一次才真退。
11
+ *
12
+ * 两个入口(主界面 `ui/app.tsx`、看板 `agent-view/dashboard.tsx`)共用这一份,
13
+ * 而不是各写一遍 —— 同一条语义有两份实现,迟早会长成两个东西。
14
+ *
15
+ * 注意 `isArmed()` 与 `armed` 是**两条路**,不是冗余:
16
+ * - 判据要走 `isArmed()`(ref)—— 在同一个按键回调里 `arm()` 之后 state 还没
17
+ * 重渲染,用 state 读的话第二次按键看到的仍是 false,就永远退不出去;
18
+ * - `armed`(state)只给渲染用(页脚那句提示)。
19
+ */
20
+ export interface CtrlCConfirm {
21
+ /** 渲染用:当前是否处于「待确认」窗口内。 */
22
+ armed: boolean
23
+ /** 判据用:同步读数,同一次按键回调里 `arm()` 之后立刻为 true。 */
24
+ isArmed: () => boolean
25
+ /** 上膛:进入待确认窗口,`windowMs` 后自动撤。 */
26
+ arm: () => void
27
+ /** 撤膛:关掉窗口并清掉计时器(例如这次按键被别的事情消费掉了)。 */
28
+ reset: () => void
29
+ }
30
+
31
+ export function useCtrlCConfirm(windowMs = 2000): CtrlCConfirm {
32
+ const armedRef = useRef(false)
33
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
34
+ const [armed, setArmed] = useState(false)
35
+
36
+ const clear = useCallback(() => {
37
+ if (timerRef.current) clearTimeout(timerRef.current)
38
+ timerRef.current = null
39
+ }, [])
40
+
41
+ const reset = useCallback(() => {
42
+ clear()
43
+ armedRef.current = false
44
+ setArmed(false)
45
+ }, [clear])
46
+
47
+ const arm = useCallback(() => {
48
+ clear()
49
+ armedRef.current = true
50
+ setArmed(true)
51
+ timerRef.current = setTimeout(() => {
52
+ armedRef.current = false
53
+ setArmed(false)
54
+ }, windowMs)
55
+ }, [clear, windowMs])
56
+
57
+ // 卸载时撤掉计时器 —— 否则它到点后会往已经卸掉的组件里 setState。
58
+ useEffect(() => clear, [clear])
59
+
60
+ const isArmed = useCallback(() => armedRef.current, [])
61
+
62
+ return { armed, isArmed, arm, reset }
63
+ }