@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.
- package/bin/mipham.ts +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +67 -6
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +106 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/commands/project.ts +5 -3
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +144 -41
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/context.ts +35 -6
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/instructions.ts +69 -48
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-config.ts +28 -7
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/attach-protocol.ts +30 -3
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +75 -17
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/arg-validation.ts +74 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/skills/bundled-skills.ts +1 -1
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +78 -14
- package/src/ui/commands.ts +73 -31
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
package/src/tools/exec/task.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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)
|
|
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
|
|
380
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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 (
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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), {
|
|
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 (
|
|
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
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
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
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5359
|
-
|
|
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
|
+
}
|