@miphamai/cli 0.85.2 → 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.
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.2",
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
 
@@ -7,6 +7,7 @@ import { getBackgroundAgentRegistry } from './background-registry'
7
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'
@@ -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
  }
@@ -385,7 +389,24 @@ export class SubAgent {
385
389
  let hitMaxTurns = false
386
390
 
387
391
  let currentMessages = messages
388
- 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
389
410
  let totalTokens = 0
390
411
 
391
412
  // Context for the sub-agent's own tool calls. Built once per run: the caller's
@@ -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
 
@@ -8,12 +8,14 @@
8
8
  * mipham agents (from CLI)
9
9
  * /agents (from slash command within a running session)
10
10
  */
11
- import React, { useState, useCallback, useMemo } from 'react'
11
+ import React, { useState, useCallback, useEffect, useMemo } from 'react'
12
12
  import { Box, Text, useInput } from 'ink'
13
+ import TextInput from 'ink-text-input'
13
14
  import { AgentViewManager, type AgentSession, type SessionStatus } from './agent-view-manager'
14
15
  import { SessionRow } from './session-row'
15
16
  import { SessionPeek } from './session-peek'
16
17
  import { useCtrlCConfirm } from '../ui/ctrl-c-confirm'
18
+ import { getBackgroundAgentRegistry } from '../agent/background-registry'
17
19
 
18
20
  interface DashboardProps {
19
21
  manager: AgentViewManager
@@ -33,8 +35,10 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
33
35
  const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
34
36
  const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
35
37
  const [feedback, setFeedback] = useState<string | null>(null)
36
- // Bump to force flatList recompute after a session is removed (list membership change).
38
+ // Bump to force flatList recompute after the session set changes.
37
39
  const [version, setVersion] = useState(0)
40
+ // Non-null while the rename box is open; holds the row being renamed + the draft.
41
+ const [renaming, setRenaming] = useState<{ id: string; draft: string } | null>(null)
38
42
 
39
43
  // Ctrl+C 的「再按一次才退」,与主界面同源(见 ui/ctrl-c-confirm.ts)
40
44
  const ctrlC = useCtrlCConfirm()
@@ -45,6 +49,12 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
45
49
  setTimeout(() => setFeedback(null), 1800)
46
50
  }, [])
47
51
 
52
+ // The mutations that matter happen outside this component: `/bg` and `/fork`
53
+ // resolve their executors long after the keystroke that spawned them, so a
54
+ // run that finishes while this panel is open used to leave the row — and the
55
+ // header counts — frozen at whatever they were when the panel mounted.
56
+ useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
57
+
48
58
  // Build a flat list of sessions in group order, with group headers
49
59
  const flatList = useMemo(() => {
50
60
  const result: Array<
@@ -115,7 +125,37 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
115
125
  [manager, onAttach],
116
126
  )
117
127
 
128
+ // Commit the open rename box. Empty title cancels — a session with no name is
129
+ // indistinguishable in the list, so the box refuses rather than writing ''.
130
+ const handleRenameSubmit = useCallback(
131
+ (value: string) => {
132
+ const id = renaming?.id
133
+ setRenaming(null)
134
+ if (!id) return
135
+ const title = value.trim()
136
+ if (!title) {
137
+ showFeedback('Rename cancelled — title cannot be empty')
138
+ return
139
+ }
140
+ manager.rename(id, title)
141
+ showFeedback(`Renamed to ${title}`)
142
+ },
143
+ [manager, renaming, showFeedback],
144
+ )
145
+
118
146
  useInput((input, key) => {
147
+ // While the rename box is open it owns the keyboard: the list keys below
148
+ // must not fire on the same keystroke that is being typed into the title
149
+ // (Ink delivers every key to every mounted useInput).
150
+ if (renaming) {
151
+ if (key.escape || (key.ctrl && input === 'c')) {
152
+ setRenaming(null)
153
+ ctrlC.reset()
154
+ showFeedback('Rename cancelled')
155
+ }
156
+ return
157
+ }
158
+
119
159
  // Ctrl+C 不再一下就退出(Ink 的 `exitOnCtrlC` 已在 render 处关掉,见
120
160
  // src/index.tsx):第一次只提示,再按一次才走。面板里 Esc 已经是退出键,
121
161
  // 所以这里只补「误按一次不带走整个面板」。
@@ -150,7 +190,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
150
190
  return
151
191
  }
152
192
 
153
- // Ctrl+R — rename selected session
193
+ // Ctrl+R — open the rename box on the selected session
154
194
  if (key.ctrl && input === 'r') {
155
195
  if (sessionsOnly.length === 0) {
156
196
  showFeedback('No sessions to rename')
@@ -158,13 +198,13 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
158
198
  }
159
199
  const current = sessionsOnly[selectedIndex]
160
200
  if (!current) return
161
- const newTitle = `session-${Date.now().toString(36)}`
162
- manager.rename(current.session.id, newTitle)
163
- showFeedback(`Renamed to ${newTitle}`)
201
+ // Seeded with the current title: renaming is usually an edit, and a box
202
+ // that opens blank silently invites the user to retype a whole task name.
203
+ setRenaming({ id: current.session.id, draft: current.session.title })
164
204
  return
165
205
  }
166
206
 
167
- // Ctrl+X — permanently remove the selected session
207
+ // Ctrl+X — stop the selected session's work (if any) and drop its row
168
208
  if (key.ctrl && input === 'x') {
169
209
  if (sessionsOnly.length === 0) {
170
210
  showFeedback('No sessions to remove')
@@ -172,11 +212,20 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
172
212
  }
173
213
  const current = sessionsOnly[selectedIndex]
174
214
  if (!current) return
175
- manager.remove(current.session.id)
215
+ const session = current.session
216
+ // Discarding a row whose work is still running would throw away the only
217
+ // handle to it — the manager's own `kill()` flips a status label and
218
+ // stops nothing, and the running sub-agent checks the registry's abort
219
+ // signal. So: stop the task, then remove the row.
220
+ const stopped = session.taskId ? getBackgroundAgentRegistry().stop(session.taskId) : false
221
+ manager.remove(session.id)
176
222
  setPeekingSessionId(null)
177
223
  setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
178
- setVersion((v) => v + 1)
179
- showFeedback(`Removed ${current.session.title || current.session.id}`)
224
+ showFeedback(
225
+ stopped
226
+ ? `Stopped + removed ${session.title || session.id}`
227
+ : `Removed ${session.title || session.id}`,
228
+ )
180
229
  return
181
230
  }
182
231
 
@@ -220,11 +269,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
220
269
  }
221
270
  const current = sessionsOnly[selectedIndex]
222
271
  if (!current) return
223
- if (peekingSessionId) {
224
- handleAttach(current.session.id)
225
- } else {
226
- handleAttach(current.session.id)
227
- }
272
+ handleAttach(current.session.id)
228
273
  return
229
274
  }
230
275
  })
@@ -233,7 +278,9 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
233
278
  const peekData = useMemo(() => {
234
279
  if (!peekingSessionId) return null
235
280
  return manager.peek(peekingSessionId) ?? null
236
- }, [manager, peekingSessionId])
281
+ // `version` is a dependency so an open peek keeps up with a session that
282
+ // reports new messages while the viewer sits on it.
283
+ }, [manager, peekingSessionId, version])
237
284
 
238
285
  const totalSessions = sessionsOnly.length
239
286
  const counts = manager.countByStatus()
@@ -323,6 +370,27 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
323
370
  <SessionPeek session={peekData.session} recentMessages={peekData.recentMessages} />
324
371
  )}
325
372
 
373
+ {/* Rename box — opens on Ctrl+R, owns the keyboard until Enter/Esc */}
374
+ {renaming && (
375
+ <Box
376
+ flexDirection="column"
377
+ marginTop={1}
378
+ borderStyle="single"
379
+ borderColor="cyan"
380
+ padding={1}
381
+ >
382
+ <Text dimColor>Rename session — Enter to save · Esc to cancel</Text>
383
+ <Box>
384
+ <Text color="cyan">{'> '}</Text>
385
+ <TextInput
386
+ value={renaming.draft}
387
+ onChange={(draft) => setRenaming((cur) => (cur ? { ...cur, draft } : cur))}
388
+ onSubmit={handleRenameSubmit}
389
+ />
390
+ </Box>
391
+ </Box>
392
+ )}
393
+
326
394
  {/* Feedback toast — flashes briefly on action */}
327
395
  {feedback && (
328
396
  <Box marginTop={1}>
@@ -0,0 +1,128 @@
1
+ /**
2
+ * AgentSessionView — where Enter ("attach") in the Agent View dashboard lands.
3
+ *
4
+ * Read-only on purpose. The session's work runs inside its own `SubAgent` with
5
+ * its own context; this CLI has no channel back into that loop, so a view that
6
+ * accepted input would be collecting keystrokes it could not deliver. What this
7
+ * shows is the real thing instead: the transcript the session actually
8
+ * accumulated, kept current through the manager's change notifications.
9
+ *
10
+ * Esc returns to the dashboard.
11
+ */
12
+ import React, { useCallback, useEffect, useMemo, useState } from 'react'
13
+ import { Box, Text, useInput } from 'ink'
14
+ import { AgentViewManager, type SessionStatus } from './agent-view-manager'
15
+ import { ChatPanel } from '../ui/chat'
16
+
17
+ interface AgentSessionViewProps {
18
+ manager: AgentViewManager
19
+ sessionId: string
20
+ onDetach: () => void
21
+ }
22
+
23
+ const STATUS_COLORS: Record<SessionStatus, string> = {
24
+ 'needs-input': 'yellow',
25
+ working: 'cyan',
26
+ completed: 'green',
27
+ failed: 'red',
28
+ }
29
+
30
+ const STATUS_LABELS: Record<SessionStatus, string> = {
31
+ 'needs-input': 'needs input',
32
+ working: 'working',
33
+ completed: 'completed',
34
+ failed: 'failed',
35
+ }
36
+
37
+ export function AgentSessionView({ manager, sessionId, onDetach }: AgentSessionViewProps) {
38
+ const [version, setVersion] = useState(0)
39
+
40
+ // The session keeps running while we look at it — follow it rather than
41
+ // photographing it once at mount.
42
+ useEffect(() => manager.onChange(() => setVersion((v) => v + 1)), [manager])
43
+
44
+ const session = useMemo(
45
+ () => manager.get(sessionId),
46
+ // `version` is a dependency so the transcript keeps up with a session that
47
+ // reports new messages while the viewer sits on it.
48
+ [manager, sessionId, version],
49
+ )
50
+
51
+ useInput((_input, key) => {
52
+ if (key.escape) onDetach()
53
+ })
54
+
55
+ // Esc while the agent view is open is the app's "go back" — but the app-level
56
+ // handler lives in `app.tsx` and only knows its own layers, so this component
57
+ // answers for itself.
58
+ const status = session ? STATUS_LABELS[session.status] : 'gone'
59
+ const statusColor = session ? STATUS_COLORS[session.status] : 'red'
60
+
61
+ const transcript = session?.messages ?? []
62
+ const emptyState = useCallback(
63
+ () => (
64
+ <Box paddingLeft={2}>
65
+ <Text dimColor>(no messages yet)</Text>
66
+ </Box>
67
+ ),
68
+ [],
69
+ )
70
+
71
+ if (!session) {
72
+ return (
73
+ <Box flexDirection="column" padding={1} height="100%">
74
+ <Text bold color="red">
75
+ Session {sessionId} no longer exists.
76
+ </Text>
77
+ <Box marginTop={1}>
78
+ <Text dimColor>Esc back to Agent View</Text>
79
+ </Box>
80
+ </Box>
81
+ )
82
+ }
83
+
84
+ return (
85
+ <Box flexDirection="column" padding={1} height="100%">
86
+ {/* Header */}
87
+ <Box flexDirection="column" marginBottom={1}>
88
+ <Box>
89
+ <Text bold color="cyan">
90
+ Attached
91
+ </Text>
92
+ <Text dimColor> — {session.id}</Text>
93
+ </Box>
94
+ <Box>
95
+ <Text>{session.title}</Text>
96
+ <Text dimColor>
97
+ {' '}
98
+ · {session.provider}/{session.model} · <Text color={statusColor}>{status}</Text>
99
+ </Text>
100
+ </Box>
101
+ {session.branch && (
102
+ <Box>
103
+ <Text dimColor>
104
+ branch {session.branch} · worktree {session.worktree}
105
+ </Text>
106
+ </Box>
107
+ )}
108
+ <Box>
109
+ <Text dimColor>
110
+ {transcript.length} message(s) · read-only transcript · Esc back to Agent View
111
+ </Text>
112
+ </Box>
113
+ </Box>
114
+
115
+ {/* Divider */}
116
+ <Box marginBottom={1}>
117
+ <Text dimColor>{'─'.repeat(70)}</Text>
118
+ </Box>
119
+
120
+ {/* Transcript */}
121
+ {transcript.length === 0 ? (
122
+ emptyState()
123
+ ) : (
124
+ <ChatPanel messages={transcript} focusMode={false} />
125
+ )}
126
+ </Box>
127
+ )
128
+ }
@@ -240,8 +240,10 @@ Messages: ${msgs.length} in context
240
240
  Tools: ${ctx.engine.getTools().size} available
241
241
 
242
242
  Shift+Tab cycles: ${renderCycleLine()} (this session only).
243
- To persist a mode, set \`permission:\` in .mipham/config.yml — every mode below is
244
- accepted there.
243
+ To persist a mode, name it in ~/.mipham/config.yml (\`permission:\`) or in
244
+ ~/.mipham/settings.json (\`permissions.defaultMode\`) — every mode below is accepted
245
+ in either. A **project-level** file cannot set a mode: it arrives with the code, so
246
+ whoever wrote the repository would be choosing the approval gate.
245
247
 
246
248
  Modes (least → most permissive):
247
249
  ${renderModeTable()}
@@ -476,7 +478,7 @@ const setupCmd: CommandHandler = async (ctx, args) => {
476
478
  ${skills.length} loaded (${standardSkills} standard + ${miphamSkills} mipham)
477
479
 
478
480
  Permissions
479
- Mode: ${ctx.config.permission} · Tools: ${ctx.engine.getTools().size}
481
+ Mode: ${ctx.engine.getPermission().getMode()} · Tools: ${ctx.engine.getTools().size}
480
482
 
481
483
 
482
484
  ── Setup Steps ──