@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
package/bin/mipham.ts CHANGED
@@ -437,6 +437,19 @@ async function runAttachCLI(): Promise<boolean> {
437
437
  const args = process.argv.slice(2)
438
438
  if (args[0] !== 'attach') return false
439
439
 
440
+ // `--permission <mode>` is the same flag here as on the interactive path, because it
441
+ // is the same gate — it just lives on the other side of the socket. The request goes
442
+ // out as `set_mode` when the TUI starts, and the daemon's answer is what the footer
443
+ // shows: it clamps, so `--permission bypassPermissions` under an org cap comes back
444
+ // narrower. Checked before the daemon is contacted: a misspelled mode is wrong whether
445
+ // or not a daemon happens to be running.
446
+ const { parsePermissionFlag, firstPositional } = await import('../src/shared/arg-validation')
447
+ const permissionFlag = parsePermissionFlag(args)
448
+ if (permissionFlag.kind === 'error') {
449
+ console.error(permissionFlag.message)
450
+ process.exit(1)
451
+ }
452
+
440
453
  const { getPort, getDaemonStatus } = await import('../src/daemon/index')
441
454
  const { join } = await import('node:path')
442
455
  const { readFileSync, existsSync } = await import('node:fs')
@@ -479,7 +492,10 @@ async function runAttachCLI(): Promise<boolean> {
479
492
  }
480
493
 
481
494
  const latestFlag = args.includes('--latest')
482
- const sessionIdArg = args[1] && !args[1].startsWith('-') ? args[1] : undefined
495
+ // Skip flags **and the values of value-taking flags**: with `--permission plan <id>`
496
+ // the old `args[1]` read `--permission` (starts with `-`, so no id) and silently fell
497
+ // through to the session list — the flag would have looked accepted and done nothing.
498
+ const sessionIdArg = firstPositional(args.slice(1)) ?? undefined
483
499
 
484
500
  let targetSession: SessionInfo | null = null
485
501
 
@@ -534,6 +550,7 @@ async function runAttachCLI(): Promise<boolean> {
534
550
  const { runApp } = await import('../src/index')
535
551
  await runApp({
536
552
  version,
553
+ permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
537
554
  remoteSession: {
538
555
  sessionId: targetSession.id,
539
556
  port,
@@ -1198,6 +1215,10 @@ async function main() {
1198
1215
  process.argv.includes('-h') ||
1199
1216
  process.argv.slice(2).some((a) => a === 'help')
1200
1217
  ) {
1218
+ // The mode list is **derived**, never typed out here — a help screen with its own copy
1219
+ // is how it comes to advertise a mode the flag then refuses (see `arg-validation`:
1220
+ // the list in the error message and the list in the help are the same array).
1221
+ const { ALL_MODES } = await import('../src/core/permission-config')
1201
1222
  console.log(`Mipham Code — AI-powered coding terminal
1202
1223
 
1203
1224
  Usage:
@@ -1226,6 +1247,8 @@ Flags:
1226
1247
  --dump-config Print the assembled profile tree
1227
1248
  --safe-mode Skip custom agents, skills, hooks, plugins
1228
1249
  --resume <name> Open a saved session (see /resume for names)
1250
+ --permission <mode> Start in this mode: ${ALL_MODES.join('|')}
1251
+ (also accepted by 'mipham attach'; the daemon may clamp it)
1229
1252
  --version, -v, -V Print version
1230
1253
 
1231
1254
  Docs: https://mipham.ai/code
@@ -1348,9 +1371,26 @@ npm: https://www.npmjs.com/package/@miphamai/cli`)
1348
1371
  }
1349
1372
  }
1350
1373
 
1374
+ // Parse --permission <mode>: the session's starting mode. `runApp` has accepted
1375
+ // `options.permission` all along — the system prompt and the engine both read the live
1376
+ // permission system it is applied to — but **nothing ever passed it**, so the only ways
1377
+ // to pick a mode were `config.yml`, Shift+Tab after startup, or the daemon's env var.
1378
+ // Same shape as `--resume` above; same remedy, and the value is refused rather than
1379
+ // silently replaced (see `parsePermissionFlag`).
1380
+ const { parsePermissionFlag } = await import('../src/shared/arg-validation')
1381
+ const permissionFlag = parsePermissionFlag(process.argv.slice(2))
1382
+ if (permissionFlag.kind === 'error') {
1383
+ console.error(permissionFlag.message)
1384
+ process.exit(1)
1385
+ }
1386
+
1351
1387
  try {
1352
1388
  const { runApp } = await import('../src/index')
1353
- await runApp({ version: APP_VERSION, resume: resumeName })
1389
+ await runApp({
1390
+ version: APP_VERSION,
1391
+ resume: resumeName,
1392
+ permission: permissionFlag.kind === 'ok' ? permissionFlag.mode : undefined,
1393
+ })
1354
1394
  } catch (err: unknown) {
1355
1395
  const msg = err instanceof Error ? err.message : String(err)
1356
1396
  if (msg.includes('react-devtools-core')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.85.1",
3
+ "version": "0.85.3",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -360,13 +360,20 @@ Press **Shift+Tab** to change the mode live; it cycles
360
360
  `bypassPermissions` is a legal mode but is deliberately **not** on the wheel —
361
361
  it is reached by naming it in config, where the user has said what they mean.
362
362
 
363
- To persist a mode for a project, in `.mipham/config.yml`:
363
+ To persist a mode, name it in `~/.mipham/config.yml`:
364
364
 
365
365
  ```yaml
366
366
  permission: default
367
367
  ```
368
368
 
369
- Any mode name from the table above is accepted, including `bypassPermissions`.
369
+ Or in `~/.mipham/settings.json` as `permissions.defaultMode` (the key Claude Code
370
+ users already have). Any mode name from the table above is accepted in either,
371
+ including `bypassPermissions`.
372
+
373
+ A **project-level** `.mipham/config.yml` cannot set a mode — that file arrives with
374
+ the code, so whoever wrote the repository would be choosing the approval gate. A
375
+ mode found there is ignored and reported; the fix is to move the line to the user
376
+ level, or to pass `--permission <mode>` for one invocation.
370
377
 
371
378
  Or via slash command:
372
379
 
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, existsSync, unlinkSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
2
3
  import { join } from 'node:path'
3
4
  import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
4
5
  import { miphamHome } from '../core/paths.ts'
@@ -109,6 +110,6 @@ export class AgentExperience {
109
110
  },
110
111
  )
111
112
 
112
- writeFileSync(this.expFile, content, 'utf-8')
113
+ atomicWriteFileSync(this.expFile, content, { mode: 0o644 })
113
114
  }
114
115
  }
@@ -83,12 +83,16 @@ export class BackgroundAgentRegistry {
83
83
  *
84
84
  * @param description - Human-readable description
85
85
  * @param agentType - Sub-agent type (general, explore, plan, code-review)
86
- * @param executor - Async function that performs the work
86
+ * @param executor - Async function that performs the work. Given the task's
87
+ * abort signal **and its id**: the id is minted here, and it is the address
88
+ * peers send to (`SendMessage` → `MessageRouter` → this `bg-…`), so an
89
+ * executor that never learns it has no way to be reachable. Callers that do
90
+ * not need it can keep declaring just the signal.
87
91
  */
88
92
  spawn(
89
93
  description: string,
90
94
  agentType: string,
91
- executor: (signal: AbortSignal) => Promise<string>,
95
+ executor: (signal: AbortSignal, id: string) => Promise<string>,
92
96
  kind: BackgroundTaskKind = 'interactive',
93
97
  ): string {
94
98
  const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
@@ -132,7 +136,7 @@ export class BackgroundAgentRegistry {
132
136
  }
133
137
 
134
138
  // Execute in background — do NOT await
135
- executor(task.abortController.signal)
139
+ executor(task.abortController.signal, id)
136
140
  .then((result) => {
137
141
  task.status = 'completed'
138
142
  task.completedAt = new Date()
@@ -1,12 +1,5 @@
1
- import {
2
- readdirSync,
3
- readFileSync,
4
- writeFileSync,
5
- existsSync,
6
- mkdirSync,
7
- unlinkSync,
8
- statSync,
9
- } from 'node:fs'
1
+ import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync, statSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
10
3
  import { join, basename } from 'node:path'
11
4
  import { hostname } from 'node:os'
12
5
  import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
@@ -25,7 +18,7 @@ const STALE_SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — heartbeat is 30s
25
18
  export function registerActiveSession(info: SessionInfo): void {
26
19
  mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
27
20
  const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
28
- writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
21
+ atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
29
22
  }
30
23
 
31
24
  /**
@@ -36,7 +29,7 @@ export function heartbeatSession(sessionId: string): void {
36
29
  if (existsSync(filePath)) {
37
30
  // Touch the file by rewriting it
38
31
  const raw = readFileSync(filePath, 'utf-8')
39
- writeFileSync(filePath, raw, 'utf-8') // updates mtime
32
+ atomicWriteFileSync(filePath, raw, { mode: 0o644 }) // updates mtime
40
33
  }
41
34
  }
42
35
 
@@ -141,7 +134,7 @@ export function renameActiveSession(sessionId: string, newName: string): string
141
134
  const info = JSON.parse(raw) as SessionInfo
142
135
  const others = discoverSessions().filter((s) => s.id !== sessionId)
143
136
  info.name = ensureUniqueSessionName(newName, others)
144
- writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
137
+ atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
145
138
  return info.name
146
139
  }
147
140
 
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  mkdirSync,
3
- writeFileSync,
4
3
  readdirSync,
5
4
  readFileSync,
6
5
  unlinkSync,
@@ -8,6 +7,7 @@ import {
8
7
  renameSync,
9
8
  lstatSync,
10
9
  } from 'node:fs'
10
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
11
11
  import { join } from 'node:path'
12
12
  import type { CrossSessionTransport } from './transport'
13
13
  import type { AgentMessage } from '../message-bus'
@@ -69,7 +69,7 @@ export class FileInboxTransport implements CrossSessionTransport {
69
69
 
70
70
  // Atomic write: temp file then rename
71
71
  const tmpPath = filePath + '.tmp'
72
- writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), 'utf-8')
72
+ atomicWriteFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o644 })
73
73
  renameSync(tmpPath, filePath)
74
74
 
75
75
  return true
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs'
2
+ import { atomicWriteFileSync } from '../shared/atomic-write'
2
3
  import { join, dirname } from 'node:path'
3
4
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
4
5
  import { isRecoverableToolFailure } from './recoverable-failure.js'
@@ -174,7 +175,7 @@ export class EffectivenessTracker {
174
175
  for (const [k, v] of this.data) {
175
176
  obj[k] = v
176
177
  }
177
- writeFileSync(this.storePath, JSON.stringify(obj, null, 2), 'utf-8')
178
+ atomicWriteFileSync(this.storePath, JSON.stringify(obj, null, 2), { mode: 0o644 })
178
179
  }
179
180
 
180
181
  load(): void {
@@ -1,12 +1,13 @@
1
1
  import type { ProviderRegistry } from '../providers/registry'
2
2
  import type { Llm } from '../providers/llm'
3
- import type { ToolDefinition, ToolContext } from '../shared/index.ts'
3
+ import type { Message, ToolDefinition, ToolContext } from '../shared/index.ts'
4
4
  import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
5
5
  import { createAgentContext } from './agent-context'
6
6
  import { getBackgroundAgentRegistry } from './background-registry'
7
- import { getMessageBus } from './message-bus'
7
+ import { formatInboundMessage, getMessageBus } from './message-bus'
8
8
  import type { HookEngine } from '../core/hooks'
9
9
  import { PermissionSystem } from '../core/permission'
10
+ import { buildPermissionBlock } from '../core/instructions'
10
11
  import { AgentExperience } from './agent-experience'
11
12
  import { PatternAnalyzer } from './pattern-analyzer.js'
12
13
  import { getWorkspaceTrust } from '../core/workspace-trust'
@@ -91,14 +92,14 @@ export class SubAgent {
91
92
  if (options.runInBackground) {
92
93
  const bgRegistry = getBackgroundAgentRegistry()
93
94
 
94
- const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
95
+ const taskId = bgRegistry.spawn(description, agentType, async (signal, agentId) => {
95
96
  // Run the synchronous execution inside the background executor, reporting
96
97
  // cumulative token usage back to the registry for live footer display.
97
98
  const opts: SubAgentOptions = {
98
99
  ...options,
99
100
  onTokenUsage: (total) => bgRegistry.updateTokenUsage(taskId, total),
100
101
  }
101
- return this.runExecution(prompt, opts, signal)
102
+ return this.runExecution(prompt, opts, signal, agentId)
102
103
  })
103
104
 
104
105
  // Register completion callback for hook firing
@@ -148,7 +149,10 @@ export class SubAgent {
148
149
 
149
150
  // ── Synchronous execution path ──
150
151
  try {
151
- const result = await this.runExecution(prompt, options)
152
+ // A caller that spawned the registry task itself owns the only abort
153
+ // controller for it; without passing it down the run ignores every check
154
+ // inside `runExecution` and cannot be stopped from the dashboard.
155
+ const result = await this.runExecution(prompt, options, options.signal)
152
156
  if (this.hookEngine) {
153
157
  await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
154
158
  }
@@ -210,10 +214,35 @@ export class SubAgent {
210
214
  /**
211
215
  * Internal execution method — shared by sync and background paths.
212
216
  */
217
+ /**
218
+ * Drain same-process messages addressed to *this* agent into its own turn,
219
+ * mirroring `Engine.drainInboundMessages` for the main session.
220
+ *
221
+ * Only the background path has an address to drain: `bg-…` is the recipient the
222
+ * message router publishes to, and it is minted by the registry — which is why
223
+ * `spawn` hands it to the executor. Until this ran, `SendMessage` to a running
224
+ * background agent returned `success: true, routedTo: 'bus'` and the message was
225
+ * read by nobody: the bus's only reader polled `[sessionId, 'main']`, so what
226
+ * the sender was told had been delivered sat there until the 1-hour prune.
227
+ * (The same hole the max-turns notice advertises away — "Use SendMessage to
228
+ * continue this sub-agent".)
229
+ */
230
+ private drainInboundMessages(agentId: string | undefined, messages: Message[]): number {
231
+ if (!agentId) return 0
232
+ const bus = getMessageBus()
233
+ const inbound = bus.poll(agentId)
234
+ for (const msg of inbound) {
235
+ messages.push({ role: 'user', content: formatInboundMessage(msg) })
236
+ }
237
+ if (inbound.length > 0) bus.markAllRead(agentId)
238
+ return inbound.length
239
+ }
240
+
213
241
  private async runExecution(
214
242
  prompt: string,
215
243
  options: SubAgentOptions,
216
244
  signal?: AbortSignal,
245
+ agentId?: string,
217
246
  ): Promise<string> {
218
247
  if (!this.registry.getActive()) {
219
248
  throw new Error('No active provider available for sub-agent execution')
@@ -360,7 +389,24 @@ export class SubAgent {
360
389
  let hitMaxTurns = false
361
390
 
362
391
  let currentMessages = messages
363
- let currentSystemPrompt = systemPrompt
392
+ // ── 子代理报**它自己的档**,不是父档 ──
393
+ // `gate` 是每个工具调用真正过的那个闸门(上面构造的那个,缺省是 `default`);
394
+ // 闸门的模式取自 `createSubAgentPermission` 的**解析结果** —— 定义写了
395
+ // `permissionMode` 的就是它自己那一档,写 `inherit` 的才是父档 —— 这里只读结果、
396
+ // 不重算,免得同一个事实有第二份推导。读 `getMode()` 而不是定义里那个字符串:
397
+ // 组织级上限(`maxAllowedMode`/`forbiddenModes`)会静默把模式钳走,报请求档就是
398
+ // 报得比实际宽 —— 与系统提示那边(`index.tsx` 的接线行)同一个理由。
399
+ //
400
+ // 它烘在**这一处**而不是走 `ContextManager.setPermissionContextSource`:子代理的请求
401
+ // 读的是下面这个局部变量(`systemPrompt: currentSystemPrompt`),**从不读上下文**的
402
+ // 系统提示 —— 挂在上下文上会是一处装饰(有接线、请求里一个字都到不了),正是本仓库
403
+ // 反复收的那种账。同样因为后续回合把提示清成 `''`(`currentSystemPrompt = ''`,
404
+ // 首轮才带系统提示),
405
+ // 权限段随**唯一**带提示的那一轮走,而不是每轮派生。
406
+ const permissionBlock = buildPermissionBlock(gate.getMode())
407
+ let currentSystemPrompt = permissionBlock
408
+ ? `${systemPrompt}\n\n---\n\n${permissionBlock}`
409
+ : systemPrompt
364
410
  let totalTokens = 0
365
411
 
366
412
  // Context for the sub-agent's own tool calls. Built once per run: the caller's
@@ -391,6 +437,10 @@ export class SubAgent {
391
437
  throw new DOMException('Aborted', 'AbortError')
392
438
  }
393
439
 
440
+ // Per turn, not once per run: a peer can write while this agent is mid
441
+ // task, and the point of the channel is to steer the work in progress.
442
+ this.drainInboundMessages(agentId, currentMessages)
443
+
394
444
  const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
395
445
  let turnText = ''
396
446
 
@@ -506,6 +556,12 @@ export class SubAgent {
506
556
  // `createSubAgentPermission`), so the default sub-agent is unchanged.
507
557
  const decision = await gate.resolveApproval(tool, effectiveInput, { signal })
508
558
  if (decision.level === 'ask') {
559
+ // Same circuit breaker as `Engine.executeTool`, sharing its threshold:
560
+ // after this many refusals in a row, say so, so the model stops
561
+ // re-issuing the call. It matters more here — a sub-agent gets five
562
+ // turns and cannot ask anyone, so a silent retry loop spends the whole
563
+ // run on a route that is closed to it.
564
+ const limitExceeded = gate.incrementBlockCounter()
509
565
  currentMessages.push({
510
566
  role: 'user' as const,
511
567
  content:
@@ -513,10 +569,15 @@ export class SubAgent {
513
569
  `Cannot execute in non-interactive sub-agent context.` +
514
570
  (decision.source === 'classifier' && decision.classifierReason
515
571
  ? ` Classifier: ${decision.classifierReason}`
572
+ : '') +
573
+ (limitExceeded
574
+ ? '\n(Consecutive block limit reached. This route stays closed for the rest of this run — try a different approach instead of retrying this call.)'
516
575
  : ''),
517
576
  })
518
577
  continue
519
578
  }
579
+ // Allowed — the streak is broken (mirrors the engine's reset).
580
+ gate.resetBlockCounter()
520
581
 
521
582
  try {
522
583
  const result = await tool.execute(effectiveInput, toolContext)
@@ -55,6 +55,16 @@ export interface SubAgentOptions {
55
55
  worktreePath?: string
56
56
  /** Seed the sub-agent with a parent conversation prefix (e.g., fork inheritance). */
57
57
  inheritContext?: { messages: Message[] }
58
+ /**
59
+ * Abort signal for a caller-owned execution.
60
+ *
61
+ * The `runInBackground` path mints its own controller inside `SubAgent`, so it
62
+ * needs nothing here. A caller that spawned the task in the registry itself
63
+ * (`/bg`, `/fork`) holds the only handle that can stop it — and if it does not
64
+ * hand that signal down, the signal is decoration: the registry's `stop()`
65
+ * aborts a controller nobody reads, and the agent runs to completion.
66
+ */
67
+ signal?: AbortSignal
58
68
  /**
59
69
  * Who authored the prompt. `'script'` marks text computed by a workflow
60
70
  * script — which is not the user speaking, even though it arrives as the
@@ -34,6 +34,16 @@ export interface AgentSession {
34
34
  branch?: string
35
35
  /** Created PR URL (if requested) */
36
36
  prUrl?: string
37
+ /**
38
+ * Id of the `BackgroundAgentRegistry` task actually doing the work, when this
39
+ * session was spawned by `/bg` or `/fork`.
40
+ *
41
+ * The two id spaces are minted separately (`agent-…` here, `bg-…` there), so
42
+ * without this the dashboard holds a row it cannot act on: its own `kill()`
43
+ * only flips the row's status and leaves the real task running with no handle
44
+ * left in the UI. This field is that handle.
45
+ */
46
+ taskId?: string
37
47
  /** Working directory the session was spawned from (used for directory grouping). */
38
48
  directory: string
39
49
  }
@@ -60,6 +70,35 @@ export class AgentViewManager {
60
70
  private sessions: Map<string, AgentSession> = new Map()
61
71
  private sessionOrder: string[] = []
62
72
  private idCounter = 0
73
+ private listeners: Set<() => void> = new Set()
74
+
75
+ /**
76
+ * Subscribe to any mutation of the session set. Returns an unsubscribe fn.
77
+ *
78
+ * The dashboard renders from this object, but the changes originate elsewhere
79
+ * (`/bg` and `/fork` mutate it from their executors, which resolve long after
80
+ * the keystroke that spawned them). Without a subscription the view is a
81
+ * snapshot painted once at mount: `setVersion` had exactly one caller
82
+ * (Ctrl+X), so a row that finished while the panel was open stayed `working`
83
+ * on screen forever.
84
+ */
85
+ onChange(listener: () => void): () => void {
86
+ this.listeners.add(listener)
87
+ return () => {
88
+ this.listeners.delete(listener)
89
+ }
90
+ }
91
+
92
+ /** One listener throwing must not stop the others, nor the mutation itself. */
93
+ private notify(): void {
94
+ for (const listener of this.listeners) {
95
+ try {
96
+ listener()
97
+ } catch {
98
+ // A broken view must never break the state transition that triggered it.
99
+ }
100
+ }
101
+ }
63
102
 
64
103
  /**
65
104
  * Create a new background agent session.
@@ -82,6 +121,7 @@ export class AgentViewManager {
82
121
 
83
122
  this.sessions.set(id, session)
84
123
  this.sessionOrder.push(id)
124
+ this.notify()
85
125
 
86
126
  return session
87
127
  }
@@ -171,6 +211,7 @@ export class AgentViewManager {
171
211
  session.startedAt = new Date()
172
212
  }
173
213
 
214
+ this.notify()
174
215
  return session
175
216
  }
176
217
 
@@ -189,6 +230,7 @@ export class AgentViewManager {
189
230
 
190
231
  session.status = 'failed'
191
232
  session.completedAt = new Date()
233
+ this.notify()
192
234
  return true
193
235
  }
194
236
 
@@ -208,6 +250,7 @@ export class AgentViewManager {
208
250
  session.completedAt = new Date()
209
251
  }
210
252
 
253
+ this.notify()
211
254
  return true
212
255
  }
213
256
 
@@ -219,6 +262,7 @@ export class AgentViewManager {
219
262
  if (!session) return false
220
263
 
221
264
  session.messages.push(message)
265
+ this.notify()
222
266
  return true
223
267
  }
224
268
 
@@ -247,6 +291,7 @@ export class AgentViewManager {
247
291
  const session = this.sessions.get(id)
248
292
  if (!session) return false
249
293
  session.title = newTitle
294
+ this.notify()
250
295
  return true
251
296
  }
252
297
 
@@ -257,6 +302,7 @@ export class AgentViewManager {
257
302
  if (!this.sessions.has(id)) return false
258
303
  this.sessions.delete(id)
259
304
  this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
305
+ this.notify()
260
306
  return true
261
307
  }
262
308