@miphamai/cli 0.58.0 → 0.60.0
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/package.json +1 -1
- package/src/commands/autoloop-journal.ts +42 -2
- package/src/config/defaults.ts +1 -0
- package/src/core/engine.ts +65 -2
- package/src/core/permission.ts +45 -0
- package/src/i18n-core/locales/en-US.json +4 -0
- package/src/i18n-core/locales/zh-CN.json +4 -0
- package/src/providers/fetch-utils.ts +18 -4
- package/src/providers/openai-compat.ts +1 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +7 -0
- package/src/tools/computer/app-launcher.ts +7 -1
- package/src/tools/computer/screenshot.ts +7 -1
- package/src/tools/scheduling/schedule-wakeup.ts +15 -0
- package/src/ui/app.tsx +337 -215
- package/src/ui/commands.ts +12 -4
- package/src/ui/loop-noop.ts +23 -0
- package/src/ui/thinking.ts +21 -0
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ function ensureDir(): void {
|
|
|
8
8
|
if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
interface AutoloopJournal {
|
|
11
|
+
export interface AutoloopJournal {
|
|
12
12
|
sessionId: string
|
|
13
13
|
prompt: string
|
|
14
14
|
status: 'active' | 'completed' | 'stopped'
|
|
@@ -16,6 +16,9 @@ interface AutoloopJournal {
|
|
|
16
16
|
startedAt: string
|
|
17
17
|
lastIteration?: string
|
|
18
18
|
logs: string[]
|
|
19
|
+
startTokens?: number
|
|
20
|
+
totalTokens: number
|
|
21
|
+
maxIterations: number
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
function journalPath(sessionId: string): string {
|
|
@@ -23,7 +26,11 @@ function journalPath(sessionId: string): string {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/** Create or reset an autonomous loop journal. */
|
|
26
|
-
export function createAutoloopJournal(
|
|
29
|
+
export function createAutoloopJournal(
|
|
30
|
+
sessionId: string,
|
|
31
|
+
prompt: string,
|
|
32
|
+
startTokens = 0,
|
|
33
|
+
): AutoloopJournal {
|
|
27
34
|
ensureDir()
|
|
28
35
|
const journal: AutoloopJournal = {
|
|
29
36
|
sessionId,
|
|
@@ -32,11 +39,22 @@ export function createAutoloopJournal(sessionId: string, prompt: string): Autolo
|
|
|
32
39
|
iterations: 0,
|
|
33
40
|
startedAt: new Date().toISOString(),
|
|
34
41
|
logs: [],
|
|
42
|
+
startTokens,
|
|
43
|
+
totalTokens: 0,
|
|
44
|
+
maxIterations: 100,
|
|
35
45
|
}
|
|
36
46
|
writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
|
|
37
47
|
return journal
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
/** Accumulate token usage into the loop journal. */
|
|
51
|
+
export function recordLoopTokens(sessionId: string, delta: number): void {
|
|
52
|
+
const journal = readAutoloopJournal(sessionId)
|
|
53
|
+
if (!journal) return
|
|
54
|
+
journal.totalTokens += delta
|
|
55
|
+
writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/** Read the journal for an autonomous loop. */
|
|
41
59
|
export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
|
|
42
60
|
const path = journalPath(sessionId)
|
|
@@ -48,6 +66,19 @@ export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
|
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
|
|
69
|
+
/** End-of-loop-turn accounting: log the iteration, record the token delta, and
|
|
70
|
+
* stop the loop if it has reached its max-iteration guard (spec §七). */
|
|
71
|
+
export function recordLoopTurn(sessionId: string, summary: string, delta: number): void {
|
|
72
|
+
const journal = readAutoloopJournal(sessionId)
|
|
73
|
+
if (!journal || journal.status !== 'active') return
|
|
74
|
+
logAutoloopIteration(sessionId, summary)
|
|
75
|
+
recordLoopTokens(sessionId, delta)
|
|
76
|
+
const updated = readAutoloopJournal(sessionId)
|
|
77
|
+
if (updated && updated.iterations >= updated.maxIterations) {
|
|
78
|
+
completeAutoloopJournal(sessionId, 'stopped')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
/** Log an iteration in the autonomous loop journal. */
|
|
52
83
|
export function logAutoloopIteration(sessionId: string, summary: string): void {
|
|
53
84
|
const journal = readAutoloopJournal(sessionId)
|
|
@@ -85,6 +116,15 @@ export function listActiveAutoloops(): AutoloopJournal[] {
|
|
|
85
116
|
return journals
|
|
86
117
|
}
|
|
87
118
|
|
|
119
|
+
/** Format active loop journals as one-line rows for the /usage panel. */
|
|
120
|
+
export function formatLoopRows(journals: AutoloopJournal[]): string[] {
|
|
121
|
+
return journals.map((j) => {
|
|
122
|
+
const per = j.iterations > 0 ? Math.round(j.totalTokens / j.iterations) : 0
|
|
123
|
+
const last = j.lastIteration ? new Date(j.lastIteration).toLocaleString() : 'N/A'
|
|
124
|
+
return `${j.sessionId.slice(-8)} 🔄 ${j.iterations} iterations · ${j.totalTokens} tokens · ${per} /run · last ${last}`
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
88
128
|
/** Get the autonomous loop status message. */
|
|
89
129
|
export function getAutoloopStatus(sessionId: string): string {
|
|
90
130
|
const journal = readAutoloopJournal(sessionId)
|
package/src/config/defaults.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
13
13
|
defaultProvider: 'deepseek',
|
|
14
14
|
defaultModel: 'deepseek-v4-pro',
|
|
15
15
|
permission: 'default',
|
|
16
|
+
showThinking: 'minimal',
|
|
16
17
|
// Org 级权限限制(可选):forbiddenModes 禁指定模式 / maxAllowedMode 封顶层级;
|
|
17
18
|
// 请求被禁模式时 fail-closed 降级(如 forbiddenModes:['bypassPermissions'])。
|
|
18
19
|
// permissionRestrictions: { forbiddenModes: ['bypassPermissions'] },
|
package/src/core/engine.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
|
|
|
38
38
|
import { getMetrics } from './metrics'
|
|
39
39
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
40
40
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
41
|
+
import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
|
|
41
42
|
import { accumulateGraftSavings } from '../shared/graft-savings'
|
|
42
43
|
import { getMessageBus, type AgentMessage } from '../agent/message-bus'
|
|
43
44
|
import { createT } from '../i18n-core/t'
|
|
@@ -101,6 +102,12 @@ export class QueryEngine {
|
|
|
101
102
|
/** 注入的 LLM chat 缝。未设置时回退 this.registry(strangler-fig)。 */
|
|
102
103
|
private llm?: Llm
|
|
103
104
|
|
|
105
|
+
/** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
|
|
106
|
+
private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
|
|
107
|
+
|
|
108
|
+
/** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
|
|
109
|
+
private onWakeupEnqueued: (() => void) | null = null
|
|
110
|
+
|
|
104
111
|
constructor(
|
|
105
112
|
private registry: ProviderRegistry,
|
|
106
113
|
private context: ContextManager,
|
|
@@ -109,6 +116,36 @@ export class QueryEngine {
|
|
|
109
116
|
ruleEngine?: ExperienceRuleEngine,
|
|
110
117
|
) {
|
|
111
118
|
this.ruleEngine = ruleEngine
|
|
119
|
+
// ScheduleWakeup 的 timer 到期后,把 loop prompt 交回本引擎 re-invoke。
|
|
120
|
+
registerWakeupHandler((_sessionId, prompt, noop) => this.enqueueWakeup(prompt, noop))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Enqueue a loop prompt for later re-invocation (keep-latest — drops older wakeups). */
|
|
124
|
+
enqueueWakeup(prompt: string, noop = false): void {
|
|
125
|
+
// 队列只保留最新(丢弃旧唤醒,防堆积)
|
|
126
|
+
this.wakeupQueue = [{ prompt, noop }]
|
|
127
|
+
this.onWakeupEnqueued?.()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Register a callback fired whenever a wakeup is enqueued (the ScheduleWakeup
|
|
131
|
+
* timer fired). The app subscribes to drain the queue while idle. */
|
|
132
|
+
setOnWakeupEnqueued(cb: (() => void) | null): void {
|
|
133
|
+
this.onWakeupEnqueued = cb
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Empty the wakeup queue (stops re-invocation at the max-iteration guard). */
|
|
137
|
+
clearWakeupQueue(): void {
|
|
138
|
+
this.wakeupQueue = []
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Pop the next pending wakeup prompt, or null when the queue is empty. */
|
|
142
|
+
dequeueWakeup(): { prompt: string; noop: boolean } | null {
|
|
143
|
+
return this.wakeupQueue.shift() ?? null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether a wakeup prompt is pending re-invocation. */
|
|
147
|
+
hasPendingWakeup(): boolean {
|
|
148
|
+
return this.wakeupQueue.length > 0
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
/** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
|
|
@@ -486,8 +523,11 @@ export class QueryEngine {
|
|
|
486
523
|
yield chunk
|
|
487
524
|
|
|
488
525
|
if (chunk.type === 'error') {
|
|
526
|
+
// #23: client-generated errors are system lines, not model output —
|
|
527
|
+
// store as `system` so resume renders an error line (⚠) rather than
|
|
528
|
+
// attributing the failure to the assistant.
|
|
489
529
|
this.context.addMessage({
|
|
490
|
-
role: '
|
|
530
|
+
role: 'system',
|
|
491
531
|
content: t('errors.model_error', { error: chunk.error ?? 'Unknown error' }),
|
|
492
532
|
})
|
|
493
533
|
return
|
|
@@ -964,7 +1004,7 @@ export class QueryEngine {
|
|
|
964
1004
|
// P1-4: Increment consecutive block counter; if limit exceeded,
|
|
965
1005
|
// tell the model to move on instead of retrying.
|
|
966
1006
|
const limitExceeded = this.permission.incrementBlockCounter()
|
|
967
|
-
const baseError =
|
|
1007
|
+
const baseError = this.buildDenialError(name, tool, params)
|
|
968
1008
|
const moveOnHint = limitExceeded
|
|
969
1009
|
? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
|
|
970
1010
|
: ''
|
|
@@ -1297,6 +1337,29 @@ export class QueryEngine {
|
|
|
1297
1337
|
return this.tools
|
|
1298
1338
|
}
|
|
1299
1339
|
|
|
1340
|
+
/**
|
|
1341
|
+
* Build a rich permission-denial error naming the mode (level), the setting
|
|
1342
|
+
* (rule/level) that caused the denial, and the correct fix (#52).
|
|
1343
|
+
*/
|
|
1344
|
+
private buildDenialError(
|
|
1345
|
+
name: string,
|
|
1346
|
+
tool: ToolDefinition,
|
|
1347
|
+
params: Record<string, unknown>,
|
|
1348
|
+
): string {
|
|
1349
|
+
const { reason, rulePattern } = this.permission.explainDenial(tool, params)
|
|
1350
|
+
const mode = this.permission.getMode()
|
|
1351
|
+
switch (reason) {
|
|
1352
|
+
case 'deny-rule':
|
|
1353
|
+
return t('errors.tool_denied_deny_rule', { name, pattern: rulePattern ?? '?' })
|
|
1354
|
+
case 'ask-rule':
|
|
1355
|
+
return t('errors.tool_denied_ask_rule', { name, pattern: rulePattern ?? '?' })
|
|
1356
|
+
default:
|
|
1357
|
+
// mode-baseline / tool-default / legacy-rule / system-default — a mode
|
|
1358
|
+
// switch (or /permissions) resolves it.
|
|
1359
|
+
return t('errors.tool_denied_mode', { name, mode })
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1300
1363
|
getRuleEngine(): ExperienceRuleEngine | undefined {
|
|
1301
1364
|
return this.ruleEngine
|
|
1302
1365
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -62,6 +62,18 @@ function isVerificationCommand(input: Record<string, unknown>): boolean {
|
|
|
62
62
|
|
|
63
63
|
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Why a tool resolved to 'ask' — for rich denial errors (#52).
|
|
67
|
+
* Mirrors the resolution chain order in `check()` (first match wins).
|
|
68
|
+
*/
|
|
69
|
+
export type PermissionDenialReason =
|
|
70
|
+
| 'deny-rule' // org deny rule — overrides mode, cannot be switched away
|
|
71
|
+
| 'ask-rule' // explicit ask rule — still requires approval under any mode
|
|
72
|
+
| 'legacy-rule' // legacy exact-name rule (setRule)
|
|
73
|
+
| 'mode-baseline' // mode-specific default (acceptEdits/plan) → ask
|
|
74
|
+
| 'tool-default' // tool.permission === 'ask'
|
|
75
|
+
| 'system-default' // no rule, no tool permission → fallback ask
|
|
76
|
+
|
|
65
77
|
export class PermissionSystem {
|
|
66
78
|
private allowRules: PermissionRuleEntry[] = []
|
|
67
79
|
private denyRules: PermissionRuleEntry[] = []
|
|
@@ -344,6 +356,39 @@ export class PermissionSystem {
|
|
|
344
356
|
return this.check(tool, input) === 'bypass'
|
|
345
357
|
}
|
|
346
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Explain WHY a tool resolves to 'ask' (rich denial errors — #52).
|
|
361
|
+
*
|
|
362
|
+
* Only call after `check()` returns 'ask'; mirrors its resolution order
|
|
363
|
+
* (deny → ask → legacy → mode → tool → system). Returns the matched
|
|
364
|
+
* rule pattern when the denial came from a deny/ask rule.
|
|
365
|
+
*/
|
|
366
|
+
explainDenial(
|
|
367
|
+
tool: ToolDefinition,
|
|
368
|
+
input: Record<string, unknown>,
|
|
369
|
+
): { reason: PermissionDenialReason; rulePattern?: string } {
|
|
370
|
+
for (const rule of this.denyRules) {
|
|
371
|
+
if (this.ruleMatches(rule, tool, input)) {
|
|
372
|
+
return { reason: 'deny-rule', rulePattern: rule.pattern }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const rule of this.askRules) {
|
|
376
|
+
if (this.ruleMatches(rule, tool, input)) {
|
|
377
|
+
return { reason: 'ask-rule', rulePattern: rule.pattern }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (this.legacyRules.has(tool.name)) {
|
|
381
|
+
return { reason: 'legacy-rule' }
|
|
382
|
+
}
|
|
383
|
+
if (this.modeBaseline(tool, input) !== 'mode-baseline') {
|
|
384
|
+
return { reason: 'mode-baseline' }
|
|
385
|
+
}
|
|
386
|
+
if (tool.permission) {
|
|
387
|
+
return { reason: 'tool-default' }
|
|
388
|
+
}
|
|
389
|
+
return { reason: 'system-default' }
|
|
390
|
+
}
|
|
391
|
+
|
|
347
392
|
// ── Helpers ──
|
|
348
393
|
|
|
349
394
|
private ruleMatches(
|
|
@@ -364,6 +364,7 @@
|
|
|
364
364
|
"provider_line": "Provider: {provider}",
|
|
365
365
|
"model_line": "Model: {model}",
|
|
366
366
|
"tool_section": "── Per-Tool ──",
|
|
367
|
+
"loops_header": "── Autonomous Loops ──",
|
|
367
368
|
"hint": "Use /context for detailed stats, /compact to free space."
|
|
368
369
|
},
|
|
369
370
|
"reload_skills": {
|
|
@@ -940,6 +941,9 @@
|
|
|
940
941
|
},
|
|
941
942
|
"errors": {
|
|
942
943
|
"tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
|
|
944
|
+
"tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach.",
|
|
945
|
+
"tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or adjust the rule via /permissions.",
|
|
946
|
+
"tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or run /permissions.",
|
|
943
947
|
"tool_blocked": "Tool \"{name}\" blocked by hook",
|
|
944
948
|
"user_input_blocked": "User input blocked by hook.",
|
|
945
949
|
"dlp_blocked": "Request blocked by DLP policy.",
|
|
@@ -364,6 +364,7 @@
|
|
|
364
364
|
"provider_line": "提供商: {provider}",
|
|
365
365
|
"model_line": "模型: {model}",
|
|
366
366
|
"tool_section": "── 按工具 ──",
|
|
367
|
+
"loops_header": "── 自动循环 ──",
|
|
367
368
|
"hint": "使用 /context 查看详细统计,/compact 释放空间。"
|
|
368
369
|
},
|
|
369
370
|
"reload_skills": {
|
|
@@ -940,6 +941,9 @@
|
|
|
940
941
|
},
|
|
941
942
|
"errors": {
|
|
942
943
|
"tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
|
|
944
|
+
"tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式。",
|
|
945
|
+
"tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或通过 /permissions 调整规则。",
|
|
946
|
+
"tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或运行 /permissions。",
|
|
943
947
|
"tool_blocked": "工具 \"{name}\" 被钩子拦截",
|
|
944
948
|
"user_input_blocked": "用户输入被钩子拦截。",
|
|
945
949
|
"dlp_blocked": "请求被 DLP 策略阻止。",
|
|
@@ -24,8 +24,9 @@ function isRetryableError(err: unknown): boolean {
|
|
|
24
24
|
/**
|
|
25
25
|
* Fetch with optional timeout and retry with exponential backoff.
|
|
26
26
|
*
|
|
27
|
-
* Retries on: network errors, 5xx, 429
|
|
28
|
-
*
|
|
27
|
+
* Retries on: network errors, 5xx, 429, and the internal timeout firing
|
|
28
|
+
* (first-byte never arrived) — retried once as a transient network issue.
|
|
29
|
+
* Does NOT retry on: caller-initiated abort (init.signal), 4xx (except 429).
|
|
29
30
|
*/
|
|
30
31
|
export async function fetchWithRetry(
|
|
31
32
|
url: string,
|
|
@@ -38,7 +39,11 @@ export async function fetchWithRetry(
|
|
|
38
39
|
|
|
39
40
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
41
|
const controller = new AbortController()
|
|
41
|
-
|
|
42
|
+
let timedOut = false
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true
|
|
45
|
+
controller.abort()
|
|
46
|
+
}, timeout)
|
|
42
47
|
const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
|
|
43
48
|
|
|
44
49
|
try {
|
|
@@ -57,7 +62,16 @@ export async function fetchWithRetry(
|
|
|
57
62
|
return response
|
|
58
63
|
} catch (err) {
|
|
59
64
|
lastErr = err
|
|
60
|
-
|
|
65
|
+
// First-byte timeout is transient → retry once. Caller abort is not.
|
|
66
|
+
const retryable = timedOut || isRetryableError(err)
|
|
67
|
+
if (!retryable || attempt >= maxRetries) {
|
|
68
|
+
if (timedOut) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`API Error: No response from API (timed out after ${Math.round(timeout / 1000)}s)`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
throw err
|
|
74
|
+
}
|
|
61
75
|
await sleep(baseDelay * Math.pow(2, attempt))
|
|
62
76
|
} finally {
|
|
63
77
|
clearTimeout(timer)
|
|
@@ -21,7 +21,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
21
21
|
model: req.model,
|
|
22
22
|
messages: this.convertMessages(req.messages, req.systemPrompt),
|
|
23
23
|
stream: true,
|
|
24
|
-
max_tokens: req.maxTokens,
|
|
24
|
+
max_tokens: req.maxTokens || 8192,
|
|
25
25
|
temperature: req.temperature,
|
|
26
26
|
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
27
27
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.60.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -135,6 +135,13 @@ export interface MiphamConfig {
|
|
|
135
135
|
permissionRestrictions?: PermissionRestrictions
|
|
136
136
|
/** User-defined permission rules (allow/deny patterns), wired into the runtime PermissionSystem. */
|
|
137
137
|
permissionRules?: { allow?: string[]; deny?: string[] }
|
|
138
|
+
/**
|
|
139
|
+
* How to render the model's reasoning/thinking before the answer:
|
|
140
|
+
* - `off` → hide entirely
|
|
141
|
+
* - `minimal` → content-free "thinking…" indicator (default)
|
|
142
|
+
* - `full` → last 200 chars of the actual thinking text
|
|
143
|
+
*/
|
|
144
|
+
showThinking?: 'off' | 'minimal' | 'full'
|
|
138
145
|
providers: ProviderConfig[]
|
|
139
146
|
skills?: { paths: string[]; mcpServers: McpServerConfig[] }
|
|
140
147
|
marketplace?: {
|
|
@@ -34,6 +34,12 @@ export function launchApp(appName: string): { success: boolean; message: string
|
|
|
34
34
|
}
|
|
35
35
|
return { success: true, message: `Launched: ${appName}` }
|
|
36
36
|
} catch (err) {
|
|
37
|
-
|
|
37
|
+
// #57: on macOS an `open -a` failure is often missing Automation permission —
|
|
38
|
+
// point the user at the fix instead of a raw error.
|
|
39
|
+
const macHint =
|
|
40
|
+
process.platform === 'darwin'
|
|
41
|
+
? ' On macOS, grant Automation permission (System Settings → Privacy & Security → Automation) if prompted.'
|
|
42
|
+
: ''
|
|
43
|
+
return { success: false, message: `Failed to launch ${appName}: ${String(err)}${macHint}` }
|
|
38
44
|
}
|
|
39
45
|
}
|
|
@@ -52,6 +52,12 @@ $bitmap.Dispose()
|
|
|
52
52
|
} catch {
|
|
53
53
|
// ignore cleanup failures
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// #57: on macOS a screencapture failure is almost always missing Screen
|
|
56
|
+
// Recording permission — point the user at the fix instead of a raw error.
|
|
57
|
+
const macHint =
|
|
58
|
+
process.platform === 'darwin'
|
|
59
|
+
? '\n(macOS: grant Screen Recording permission in System Settings → Privacy & Security → Screen Recording)'
|
|
60
|
+
: ''
|
|
61
|
+
return { success: false, error: `Screenshot failed: ${String(err)}${macHint}` }
|
|
56
62
|
}
|
|
57
63
|
}
|
|
@@ -6,6 +6,15 @@ import type { ToolDefinition } from '../../shared/index.ts'
|
|
|
6
6
|
*/
|
|
7
7
|
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
8
8
|
|
|
9
|
+
let wakeupHandler: ((sessionId: string, prompt: string, noop?: boolean) => void) | null = null
|
|
10
|
+
|
|
11
|
+
/** 引擎在启动时注入——timer 到期后回调,把 loop prompt 交回引擎 re-invoke。 */
|
|
12
|
+
export function registerWakeupHandler(
|
|
13
|
+
fn: (sessionId: string, prompt: string, noop?: boolean) => void,
|
|
14
|
+
): void {
|
|
15
|
+
wakeupHandler = fn
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
export const scheduleWakeupTool: ToolDefinition = {
|
|
10
19
|
name: 'ScheduleWakeup',
|
|
11
20
|
description:
|
|
@@ -31,6 +40,10 @@ export const scheduleWakeupTool: ToolDefinition = {
|
|
|
31
40
|
type: 'boolean',
|
|
32
41
|
description: 'Set to true to end the dynamic loop immediately.',
|
|
33
42
|
},
|
|
43
|
+
noop: {
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
description: 'true = 本轮无事可报(仅用于 UI 折叠空闲提示,不改变 re-invoke 语义)',
|
|
46
|
+
},
|
|
34
47
|
},
|
|
35
48
|
required: [],
|
|
36
49
|
},
|
|
@@ -58,6 +71,7 @@ export const scheduleWakeupTool: ToolDefinition = {
|
|
|
58
71
|
const delaySeconds = params.delaySeconds as number
|
|
59
72
|
const prompt = (params.prompt as string) || ''
|
|
60
73
|
const reason = (params.reason as string) || 'scheduled wakeup'
|
|
74
|
+
const noop = params.noop === true
|
|
61
75
|
|
|
62
76
|
if (!delaySeconds || typeof delaySeconds !== 'number') {
|
|
63
77
|
return {
|
|
@@ -86,6 +100,7 @@ export const scheduleWakeupTool: ToolDefinition = {
|
|
|
86
100
|
const timerKey = `${sessionId}:wakeup`
|
|
87
101
|
const timeoutId = setTimeout(() => {
|
|
88
102
|
activeTimers.delete(timerKey)
|
|
103
|
+
wakeupHandler?.(sessionId, prompt, noop)
|
|
89
104
|
}, delaySeconds * 1000)
|
|
90
105
|
|
|
91
106
|
activeTimers.set(timerKey, timeoutId)
|
package/src/ui/app.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import TextInput from 'ink-text-input'
|
|
4
4
|
import { ErrorBoundary } from './error-boundary'
|
|
5
|
+
import { formatThinking } from './thinking'
|
|
5
6
|
import type { QueryEngine } from '../core/engine'
|
|
6
7
|
import type { RemoteEngine } from '../daemon/remote-engine'
|
|
7
8
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
@@ -23,6 +24,7 @@ import { InputBar } from './input'
|
|
|
23
24
|
import { ModelPicker } from './picker'
|
|
24
25
|
import { AgentFooter, type AgentEntry } from './agent-footer'
|
|
25
26
|
import { GraftStatusLine } from './graft-status'
|
|
27
|
+
import { collapseNoopTicks } from './loop-noop'
|
|
26
28
|
|
|
27
29
|
/** Current context-window usage % — undefined when unknown (remote stub). */
|
|
28
30
|
function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
|
|
@@ -48,6 +50,8 @@ import {
|
|
|
48
50
|
import { useI18n } from '../i18n-context'
|
|
49
51
|
import type { PermissionMode } from '../shared/index.ts'
|
|
50
52
|
import { sanitizeForDisplay } from '../shared/sanitize.ts'
|
|
53
|
+
import { recordLoopTurn, readAutoloopJournal } from '../commands/autoloop-journal.js'
|
|
54
|
+
import { cancelAllSessionTimers } from '../tools/scheduling/schedule-wakeup.js'
|
|
51
55
|
|
|
52
56
|
interface AppProps {
|
|
53
57
|
engine: QueryEngine | RemoteEngine
|
|
@@ -160,6 +164,10 @@ export function App({
|
|
|
160
164
|
)
|
|
161
165
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
162
166
|
const [isLoading, setIsLoading] = useState(false)
|
|
167
|
+
/** Idle-drain tick — bumped by the engine's onEnqueue callback whenever the
|
|
168
|
+
* ScheduleWakeup timer fires while the engine is idle. Drives the idle-drain
|
|
169
|
+
* effect so a queued /loop wakeup re-invokes without waiting for user input. */
|
|
170
|
+
const [wakeupTick, setWakeupTick] = useState(0)
|
|
163
171
|
const [providerId, setProviderId] = useState(initialProvider || config.defaultProvider)
|
|
164
172
|
const [modelId, setModelId] = useState(initialModel || config.defaultModel)
|
|
165
173
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
@@ -181,6 +189,8 @@ export function App({
|
|
|
181
189
|
// Monotonic turn id — lets a stale turn's finally() skip resetting shared UI
|
|
182
190
|
// state (isLoading/abortRef/progress) after a newer turn has already started.
|
|
183
191
|
const turnIdRef = useRef(0)
|
|
192
|
+
// Consecutive /loop noop wakeup count — used to fold repeated idle turns (#53).
|
|
193
|
+
const noopStreakRef = useRef(0)
|
|
184
194
|
// Stream buffer: accumulate text chunks and throttle state updates to ~16fps.
|
|
185
195
|
// Without this, every SSE chunk triggers setMessages → copies full array →
|
|
186
196
|
// re-renders ChatPanel → re-runs compactToolGroups O(n). At 20-50 chunks/sec
|
|
@@ -337,220 +347,50 @@ export function App({
|
|
|
337
347
|
[apiKeyPrompt, config, engine],
|
|
338
348
|
)
|
|
339
349
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
// ── runTurn: renders a single AI turn (user-submitted or /loop re-invoked) ──
|
|
351
|
+
// Extracted from handleSubmit so scheduled /loop wakeups re-enter the same streaming
|
|
352
|
+
// path. `source` marks user vs loop turns (currently identical rendering — loop turns
|
|
353
|
+
// skip handleSubmit's user-message/emotion/session-setup prelude). `controller` is
|
|
354
|
+
// supplied by user turns (already stored in abortRef); loop turns pass none and runTurn
|
|
355
|
+
// creates one so Escape/abort still works mid-loop-turn.
|
|
356
|
+
const runTurn = useCallback(
|
|
357
|
+
async (input: string, source: 'user' | 'loop', controller?: AbortController, noop = false) => {
|
|
358
|
+
void source // reserved for future user-vs-loop divergence; both paths share this body
|
|
343
359
|
const turnId = ++turnIdRef.current
|
|
360
|
+
// Loop turns set loading state here (user turns already do it in handleSubmit;
|
|
361
|
+
// the duplicate is idempotent). Without this, the idle-drain effect can't tell
|
|
362
|
+
// a loop turn is running and would drain the queue mid-turn.
|
|
363
|
+
setIsLoading(true)
|
|
364
|
+
const loopStartTokens =
|
|
365
|
+
source === 'loop' && 'hasPendingWakeup' in engine
|
|
366
|
+
? engine.getUsageTracker().totalApiTokens
|
|
367
|
+
: 0
|
|
368
|
+
const ctrl = controller ?? new AbortController()
|
|
369
|
+
if (!controller) abortRef.current = ctrl
|
|
344
370
|
|
|
345
|
-
// ──
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
setMessages((prev) => [
|
|
352
|
-
...prev,
|
|
353
|
-
{ role: 'user', content: input },
|
|
354
|
-
{ role: 'system', content: 'Usage: @session-name <message>' },
|
|
355
|
-
])
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
const mentionSummary =
|
|
359
|
-
mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
|
|
360
|
-
const result = await getMessageRouter().route(
|
|
361
|
-
sessionId || 'main',
|
|
362
|
-
mention.name,
|
|
363
|
-
mentionSummary,
|
|
364
|
-
mention.message,
|
|
365
|
-
)
|
|
366
|
-
setMessages((prev) => [
|
|
367
|
-
...prev,
|
|
368
|
-
{ role: 'user', content: input },
|
|
369
|
-
{
|
|
370
|
-
role: 'system',
|
|
371
|
-
content: result.success
|
|
372
|
-
? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
|
|
373
|
-
: `❌ Failed to send to ${mention.name}: ${result.error}`,
|
|
374
|
-
},
|
|
375
|
-
])
|
|
376
|
-
return
|
|
371
|
+
// ── /loop idle folding (#53) ──
|
|
372
|
+
// Count consecutive noop wakeups; any non-noop loop turn or user turn resets.
|
|
373
|
+
if (source === 'loop' && noop) {
|
|
374
|
+
noopStreakRef.current++
|
|
375
|
+
} else {
|
|
376
|
+
noopStreakRef.current = 0
|
|
377
377
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
const {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
setApiKeyInput('')
|
|
389
|
-
setMessages((prev) => [
|
|
390
|
-
...prev,
|
|
391
|
-
{ role: 'user', content: input },
|
|
392
|
-
{ role: 'system', content: result.content },
|
|
393
|
-
])
|
|
378
|
+
// The first noop tick renders normally; the 2nd+ fold into one idle line.
|
|
379
|
+
const foldIdle = source === 'loop' && noop && noopStreakRef.current >= 2
|
|
380
|
+
if (foldIdle) {
|
|
381
|
+
const ticks = Array.from({ length: noopStreakRef.current }, () => ({ noop: true }))
|
|
382
|
+
const folded = collapseNoopTicks(ticks)
|
|
383
|
+
setMessages((prev) => {
|
|
384
|
+
const updated = [...prev]
|
|
385
|
+
const last = updated[updated.length - 1]
|
|
386
|
+
if (last?.role === 'system' && last.content.startsWith('💤 idle ×')) {
|
|
387
|
+
last.content = folded
|
|
394
388
|
} else {
|
|
395
|
-
|
|
396
|
-
...prev,
|
|
397
|
-
{ role: 'user', content: input },
|
|
398
|
-
{ role: 'system', content: result.content },
|
|
399
|
-
])
|
|
400
|
-
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
401
|
-
if (result.nextModel) setModelId(result.nextModel)
|
|
402
|
-
}
|
|
403
|
-
if (result.exit) process.exit(0)
|
|
404
|
-
return
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// /pick → open interactive model picker
|
|
408
|
-
if (command === '/pick' || command === '/model-picker') {
|
|
409
|
-
setPickerOpen(true)
|
|
410
|
-
return
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// /quit and /exit are special
|
|
414
|
-
if (command === '/exit' || command === '/quit') {
|
|
415
|
-
process.exit(0)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// /focus toggle
|
|
419
|
-
if (command === '/focus') {
|
|
420
|
-
const nextFocus = !focusMode
|
|
421
|
-
setFocusMode(nextFocus)
|
|
422
|
-
setMessages((prev) => [
|
|
423
|
-
...prev,
|
|
424
|
-
{ role: 'user', content: input },
|
|
425
|
-
{
|
|
426
|
-
role: 'system',
|
|
427
|
-
content: nextFocus
|
|
428
|
-
? '✓ Focus mode ON — showing only the most recent exchange. Type /focus again to show all.'
|
|
429
|
-
: '✓ Focus mode OFF — showing all messages.',
|
|
430
|
-
},
|
|
431
|
-
])
|
|
432
|
-
return
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
let forwardToAI: string | undefined
|
|
436
|
-
|
|
437
|
-
const handler = getCommand(command)
|
|
438
|
-
if (handler) {
|
|
439
|
-
const result = await handler(mkCtx(), args)
|
|
440
|
-
forwardToAI = result.forwardToAI
|
|
441
|
-
// Handle API key prompt from command result
|
|
442
|
-
if (result.needsApiKey) {
|
|
443
|
-
setApiKeyPrompt(result.needsApiKey)
|
|
444
|
-
setApiKeyInput('')
|
|
445
|
-
}
|
|
446
|
-
setMessages((prev) => [
|
|
447
|
-
...prev,
|
|
448
|
-
{ role: 'user', content: input },
|
|
449
|
-
{ role: 'system', content: result.content },
|
|
450
|
-
])
|
|
451
|
-
if (result.needsApiKey) {
|
|
452
|
-
// Don't process nextProvider/model when waiting for API key
|
|
453
|
-
if (result.exit) process.exit(0)
|
|
454
|
-
return
|
|
455
|
-
}
|
|
456
|
-
if (result.clearMessages) setMessages([])
|
|
457
|
-
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
458
|
-
if (result.nextModel) setModelId(result.nextModel)
|
|
459
|
-
if (result.exit) process.exit(0)
|
|
460
|
-
if (result.forwardedMessages && result.forwardedMessages.length > 0) {
|
|
461
|
-
const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
|
|
462
|
-
role: msg.role,
|
|
463
|
-
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
464
|
-
}))
|
|
465
|
-
if (result.resumeWarning) {
|
|
466
|
-
// Fix 7: Session isolation — add a visual separator between current session
|
|
467
|
-
// and restored history to prevent accidental leakage/confusion
|
|
468
|
-
const separator: ChatMessage = {
|
|
469
|
-
role: 'assistant' as const,
|
|
470
|
-
content: '── Restored session history below ──',
|
|
471
|
-
}
|
|
472
|
-
setMessages((prev) => [...prev, separator, ...restored])
|
|
473
|
-
} else {
|
|
474
|
-
setMessages((prev) => [...prev, ...restored])
|
|
475
|
-
}
|
|
389
|
+
updated.push({ role: 'system', content: folded })
|
|
476
390
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
const { execSync } = await import('node:child_process')
|
|
481
|
-
if (process.platform === 'darwin') {
|
|
482
|
-
execSync('pbcopy', { input: result.copyContent })
|
|
483
|
-
} else if (process.platform === 'win32') {
|
|
484
|
-
execSync('clip', { input: result.copyContent })
|
|
485
|
-
}
|
|
486
|
-
// Linux: xclip or wl-copy not attempted to avoid dependency issues
|
|
487
|
-
} catch {
|
|
488
|
-
// Silent fail — content is still displayed
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Bridge: if command set forwardToAI, route the message to AI processing
|
|
494
|
-
if (forwardToAI) {
|
|
495
|
-
input = forwardToAI
|
|
496
|
-
// fall through to normal AI processing below
|
|
497
|
-
} else {
|
|
498
|
-
// No handler matched or handler didn't request AI routing — stop here
|
|
499
|
-
return
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// ── Emotion detection: adjust behavior based on user's emotional state ──
|
|
504
|
-
// Uses regex heuristics (zero-latency) to detect frustration/impatience/confusion.
|
|
505
|
-
// When frustrated, prepends a terseness instruction to the user input so the
|
|
506
|
-
// AI model skips explanations and gets straight to the fix.
|
|
507
|
-
let emotionPrefix = ''
|
|
508
|
-
try {
|
|
509
|
-
const { EmotionDetector } = await import('../core/emotion-detector.js')
|
|
510
|
-
const detector = new EmotionDetector()
|
|
511
|
-
const result = detector.detect(input)
|
|
512
|
-
if (result.emotion === 'frustrated' || result.emotion === 'impatient') {
|
|
513
|
-
emotionPrefix = `[SYSTEM NOTE: The user is ${result.emotion}. Be extremely concise. Skip all explanations, preambles, and summaries. Output only the fix/result. No "here's what I did" or "let me explain". One sentence maximum before code.]\n\n`
|
|
514
|
-
} else if (result.emotion === 'confused') {
|
|
515
|
-
emotionPrefix = `[SYSTEM NOTE: The user seems confused. Explain more thoroughly, break down complex steps, and offer clarifying questions rather than assuming understanding.]\n\n`
|
|
516
|
-
}
|
|
517
|
-
} catch {
|
|
518
|
-
// Emotion detection is non-critical — fail silently
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// ── Normal message processing (AI chat) ──
|
|
522
|
-
// First user message: auto-name the session if it still carries the
|
|
523
|
-
// default cwd-basename name (respecting any manual /rename).
|
|
524
|
-
if (
|
|
525
|
-
engine
|
|
526
|
-
.getContext()
|
|
527
|
-
.getMessages()
|
|
528
|
-
.every((m) => m.role !== 'user')
|
|
529
|
-
) {
|
|
530
|
-
const currentName = discoverSessions().find((s) => s.id === sessionId)?.name
|
|
531
|
-
if (isDefaultSessionName(currentName, process.cwd())) {
|
|
532
|
-
const title = deriveSessionTitle(input)
|
|
533
|
-
if (title && sessionId) renameActiveSession(sessionId, title)
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
setMessages((prev) => [...prev, { role: 'user', content: input }])
|
|
538
|
-
setIsLoading(true)
|
|
539
|
-
|
|
540
|
-
// Start agent progress indicator immediately for ALL processing
|
|
541
|
-
const progressStart = Date.now()
|
|
542
|
-
const progress: AgentProgress = {
|
|
543
|
-
name: '',
|
|
544
|
-
description: '',
|
|
545
|
-
startTime: progressStart,
|
|
546
|
-
tokensUsed: 0,
|
|
391
|
+
return updated
|
|
392
|
+
})
|
|
547
393
|
}
|
|
548
|
-
agentProgressRef.current = progress
|
|
549
|
-
setAgentProgress(progress)
|
|
550
|
-
setAgentTick((t) => t + 1)
|
|
551
|
-
|
|
552
|
-
const controller = new AbortController()
|
|
553
|
-
abortRef.current = controller
|
|
554
394
|
|
|
555
395
|
let assistantContent = ''
|
|
556
396
|
// Track whether we've started a new assistant turn — reset accumulator per turn
|
|
@@ -577,13 +417,11 @@ export function App({
|
|
|
577
417
|
}
|
|
578
418
|
|
|
579
419
|
try {
|
|
580
|
-
for await (const chunk of engine.process(
|
|
581
|
-
emotionPrefix ? emotionPrefix + input : input,
|
|
582
|
-
controller.signal,
|
|
583
|
-
)) {
|
|
420
|
+
for await (const chunk of engine.process(input, ctrl.signal)) {
|
|
584
421
|
// Reasoning content (DeepSeek V4 thinking mode) — surface as a live
|
|
585
422
|
// "thinking" indicator so long reasoning passes don't look like a stall.
|
|
586
423
|
if (chunk.reasoning_content) {
|
|
424
|
+
if (foldIdle) continue // folded idle turns never surface reasoning
|
|
587
425
|
thinkingRef.current += chunk.reasoning_content
|
|
588
426
|
if (!thinkingTimerRef.current) {
|
|
589
427
|
thinkingTimerRef.current = setTimeout(() => {
|
|
@@ -595,6 +433,8 @@ export function App({
|
|
|
595
433
|
}
|
|
596
434
|
|
|
597
435
|
if (chunk.type === 'text' && chunk.content) {
|
|
436
|
+
assistantContent += chunk.content
|
|
437
|
+
if (foldIdle) continue // suppress per-turn idle output; folded line already shown
|
|
598
438
|
// New turn: push fresh assistant message, reset stream buffer
|
|
599
439
|
if (isNewTurn) {
|
|
600
440
|
// Flush any accumulated reasoning as a collapsed history line
|
|
@@ -638,7 +478,6 @@ export function App({
|
|
|
638
478
|
}, 60)
|
|
639
479
|
}
|
|
640
480
|
}
|
|
641
|
-
assistantContent += chunk.content
|
|
642
481
|
}
|
|
643
482
|
|
|
644
483
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
@@ -806,8 +645,284 @@ export function App({
|
|
|
806
645
|
// Final sync of background agents after the turn completes
|
|
807
646
|
syncBgAgents()
|
|
808
647
|
}
|
|
648
|
+
|
|
649
|
+
// End-of-loop-turn accounting: log iteration + token delta, and stop at the
|
|
650
|
+
// max-iteration guard. `input` is the loop id for /loop auto (ScheduleWakeup
|
|
651
|
+
// re-invokes runTurn(loopId, 'loop')); fixed-interval loops have no journal and skip.
|
|
652
|
+
if (source === 'loop' && 'hasPendingWakeup' in engine) {
|
|
653
|
+
const journal = readAutoloopJournal(input)
|
|
654
|
+
if (journal && journal.status === 'active') {
|
|
655
|
+
const delta = engine.getUsageTracker().totalApiTokens - loopStartTokens
|
|
656
|
+
recordLoopTurn(input, assistantContent.slice(0, 200), delta)
|
|
657
|
+
// max-iteration guard: recordLoopTurn flips the journal to 'stopped' when
|
|
658
|
+
// iterations hit maxIterations. Stop re-invocation so the loop doesn't wake
|
|
659
|
+
// again after hitting the cap.
|
|
660
|
+
const after = readAutoloopJournal(input)
|
|
661
|
+
if (after && after.status !== 'active') {
|
|
662
|
+
if (sessionId) cancelAllSessionTimers(sessionId)
|
|
663
|
+
engine.clearWakeupQueue()
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Turn finished — drain any /loop wakeup queued while we were running.
|
|
669
|
+
drainLoopQueueRef.current?.(turnId)
|
|
670
|
+
},
|
|
671
|
+
[engine, syncBgAgents],
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
// Ref-held drain breaks the runTurn ↔ drain circular useCallback dependency: runTurn
|
|
675
|
+
// calls drainLoopQueueRef.current (a stable ref object), and the drain closure captures
|
|
676
|
+
// the latest runTurn. Reassigned each render so it never holds a stale runTurn.
|
|
677
|
+
const drainLoopQueueRef = useRef<((turnId: number) => Promise<void>) | null>(null)
|
|
678
|
+
drainLoopQueueRef.current = async (turnId: number) => {
|
|
679
|
+
// User turn wins: if a user submitted meanwhile, turnId advanced and this loop wakeup
|
|
680
|
+
// yields (the next timer fire re-enqueues). Spec §六 — do not force it to run anyway.
|
|
681
|
+
if (turnIdRef.current !== turnId) return
|
|
682
|
+
// RemoteEngine (remote attach mode) has no wakeup queue — /loop is CLI-local.
|
|
683
|
+
if (!('hasPendingWakeup' in engine)) return
|
|
684
|
+
const next = engine.hasPendingWakeup() ? engine.dequeueWakeup() : null
|
|
685
|
+
if (next) await runTurn(next.prompt, 'loop', undefined, next.noop)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── /loop idle-drain trigger ──
|
|
689
|
+
// The ScheduleWakeup timer fires 60–3600s after the turn that scheduled it, while
|
|
690
|
+
// the engine is idle. At that moment enqueueWakeup only mutates the queue (no React
|
|
691
|
+
// state change, no poller), so the queue would sit undrained forever. Subscribe the
|
|
692
|
+
// engine's onEnqueue callback to a state bump; the idle-drain effect below then
|
|
693
|
+
// drains the queue and re-invokes runTurn.
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
if (!('setOnWakeupEnqueued' in engine)) return
|
|
696
|
+
const qe = engine // narrow to QueryEngine for the cleanup closure
|
|
697
|
+
qe.setOnWakeupEnqueued(() => setWakeupTick((t) => t + 1))
|
|
698
|
+
return () => qe.setOnWakeupEnqueued(null)
|
|
699
|
+
}, [engine])
|
|
700
|
+
|
|
701
|
+
// When a wakeup is enqueued and the engine is idle, drain it now (the running turn's
|
|
702
|
+
// end-drain handles the busy case). `wakeupTick === 0` skips the mount-time run.
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
if (wakeupTick === 0) return
|
|
705
|
+
if (isLoading) return
|
|
706
|
+
drainLoopQueueRef.current?.(turnIdRef.current)
|
|
707
|
+
}, [wakeupTick, isLoading])
|
|
708
|
+
|
|
709
|
+
const handleSubmit = useCallback(
|
|
710
|
+
async (input: string) => {
|
|
711
|
+
if (!input.trim()) return
|
|
712
|
+
|
|
713
|
+
// ── @mention: direct cross-session message — only when the name resolves
|
|
714
|
+
// to a live session; otherwise fall through to normal AI processing so a
|
|
715
|
+
// leading `@word` (e.g. "@bob please review") isn't hijacked as a send. ──
|
|
716
|
+
const mention = parseMention(input)
|
|
717
|
+
if (mention && resolveRecipientSession(discoverSessions(), mention.name).session) {
|
|
718
|
+
if (!mention.message) {
|
|
719
|
+
setMessages((prev) => [
|
|
720
|
+
...prev,
|
|
721
|
+
{ role: 'user', content: input },
|
|
722
|
+
{ role: 'system', content: 'Usage: @session-name <message>' },
|
|
723
|
+
])
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
const mentionSummary =
|
|
727
|
+
mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
|
|
728
|
+
const result = await getMessageRouter().route(
|
|
729
|
+
sessionId || 'main',
|
|
730
|
+
mention.name,
|
|
731
|
+
mentionSummary,
|
|
732
|
+
mention.message,
|
|
733
|
+
)
|
|
734
|
+
setMessages((prev) => [
|
|
735
|
+
...prev,
|
|
736
|
+
{ role: 'user', content: input },
|
|
737
|
+
{
|
|
738
|
+
role: 'system',
|
|
739
|
+
content: result.success
|
|
740
|
+
? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
|
|
741
|
+
: `❌ Failed to send to ${mention.name}: ${result.error}`,
|
|
742
|
+
},
|
|
743
|
+
])
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ── Slash command dispatch ──
|
|
748
|
+
if (looksLikeSlashCommand(input)) {
|
|
749
|
+
const { command, args } = parseSlashCommand(input)
|
|
750
|
+
|
|
751
|
+
// /switch takes args, handled separately
|
|
752
|
+
if (command === '/switch') {
|
|
753
|
+
const result = await handleSwitch(mkCtx(), args)
|
|
754
|
+
if (result.needsApiKey) {
|
|
755
|
+
setApiKeyPrompt(result.needsApiKey)
|
|
756
|
+
setApiKeyInput('')
|
|
757
|
+
setMessages((prev) => [
|
|
758
|
+
...prev,
|
|
759
|
+
{ role: 'user', content: input },
|
|
760
|
+
{ role: 'system', content: result.content },
|
|
761
|
+
])
|
|
762
|
+
} else {
|
|
763
|
+
setMessages((prev) => [
|
|
764
|
+
...prev,
|
|
765
|
+
{ role: 'user', content: input },
|
|
766
|
+
{ role: 'system', content: result.content },
|
|
767
|
+
])
|
|
768
|
+
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
769
|
+
if (result.nextModel) setModelId(result.nextModel)
|
|
770
|
+
}
|
|
771
|
+
if (result.exit) process.exit(0)
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// /pick → open interactive model picker
|
|
776
|
+
if (command === '/pick' || command === '/model-picker') {
|
|
777
|
+
setPickerOpen(true)
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// /quit and /exit are special
|
|
782
|
+
if (command === '/exit' || command === '/quit') {
|
|
783
|
+
process.exit(0)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// /focus toggle
|
|
787
|
+
if (command === '/focus') {
|
|
788
|
+
const nextFocus = !focusMode
|
|
789
|
+
setFocusMode(nextFocus)
|
|
790
|
+
setMessages((prev) => [
|
|
791
|
+
...prev,
|
|
792
|
+
{ role: 'user', content: input },
|
|
793
|
+
{
|
|
794
|
+
role: 'system',
|
|
795
|
+
content: nextFocus
|
|
796
|
+
? '✓ Focus mode ON — showing only the most recent exchange. Type /focus again to show all.'
|
|
797
|
+
: '✓ Focus mode OFF — showing all messages.',
|
|
798
|
+
},
|
|
799
|
+
])
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
let forwardToAI: string | undefined
|
|
804
|
+
|
|
805
|
+
const handler = getCommand(command)
|
|
806
|
+
if (handler) {
|
|
807
|
+
const result = await handler(mkCtx(), args)
|
|
808
|
+
forwardToAI = result.forwardToAI
|
|
809
|
+
// Handle API key prompt from command result
|
|
810
|
+
if (result.needsApiKey) {
|
|
811
|
+
setApiKeyPrompt(result.needsApiKey)
|
|
812
|
+
setApiKeyInput('')
|
|
813
|
+
}
|
|
814
|
+
setMessages((prev) => [
|
|
815
|
+
...prev,
|
|
816
|
+
{ role: 'user', content: input },
|
|
817
|
+
{ role: 'system', content: result.content },
|
|
818
|
+
])
|
|
819
|
+
if (result.needsApiKey) {
|
|
820
|
+
// Don't process nextProvider/model when waiting for API key
|
|
821
|
+
if (result.exit) process.exit(0)
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
if (result.clearMessages) setMessages([])
|
|
825
|
+
if (result.nextProvider) setProviderId(result.nextProvider)
|
|
826
|
+
if (result.nextModel) setModelId(result.nextModel)
|
|
827
|
+
if (result.exit) process.exit(0)
|
|
828
|
+
if (result.forwardedMessages && result.forwardedMessages.length > 0) {
|
|
829
|
+
const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
|
|
830
|
+
role: msg.role,
|
|
831
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
832
|
+
}))
|
|
833
|
+
if (result.resumeWarning) {
|
|
834
|
+
// Fix 7: Session isolation — add a visual separator between current session
|
|
835
|
+
// and restored history to prevent accidental leakage/confusion
|
|
836
|
+
const separator: ChatMessage = {
|
|
837
|
+
role: 'assistant' as const,
|
|
838
|
+
content: '── Restored session history below ──',
|
|
839
|
+
}
|
|
840
|
+
setMessages((prev) => [...prev, separator, ...restored])
|
|
841
|
+
} else {
|
|
842
|
+
setMessages((prev) => [...prev, ...restored])
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (result.copyContent) {
|
|
846
|
+
// Copy to clipboard via pbcopy (macOS) or clip (Windows)
|
|
847
|
+
try {
|
|
848
|
+
const { execSync } = await import('node:child_process')
|
|
849
|
+
if (process.platform === 'darwin') {
|
|
850
|
+
execSync('pbcopy', { input: result.copyContent })
|
|
851
|
+
} else if (process.platform === 'win32') {
|
|
852
|
+
execSync('clip', { input: result.copyContent })
|
|
853
|
+
}
|
|
854
|
+
// Linux: xclip or wl-copy not attempted to avoid dependency issues
|
|
855
|
+
} catch {
|
|
856
|
+
// Silent fail — content is still displayed
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Bridge: if command set forwardToAI, route the message to AI processing
|
|
862
|
+
if (forwardToAI) {
|
|
863
|
+
input = forwardToAI
|
|
864
|
+
// fall through to normal AI processing below
|
|
865
|
+
} else {
|
|
866
|
+
// No handler matched or handler didn't request AI routing — stop here
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── Emotion detection: adjust behavior based on user's emotional state ──
|
|
872
|
+
// Uses regex heuristics (zero-latency) to detect frustration/impatience/confusion.
|
|
873
|
+
// When frustrated, prepends a terseness instruction to the user input so the
|
|
874
|
+
// AI model skips explanations and gets straight to the fix.
|
|
875
|
+
let emotionPrefix = ''
|
|
876
|
+
try {
|
|
877
|
+
const { EmotionDetector } = await import('../core/emotion-detector.js')
|
|
878
|
+
const detector = new EmotionDetector()
|
|
879
|
+
const result = detector.detect(input)
|
|
880
|
+
if (result.emotion === 'frustrated' || result.emotion === 'impatient') {
|
|
881
|
+
emotionPrefix = `[SYSTEM NOTE: The user is ${result.emotion}. Be extremely concise. Skip all explanations, preambles, and summaries. Output only the fix/result. No "here's what I did" or "let me explain". One sentence maximum before code.]\n\n`
|
|
882
|
+
} else if (result.emotion === 'confused') {
|
|
883
|
+
emotionPrefix = `[SYSTEM NOTE: The user seems confused. Explain more thoroughly, break down complex steps, and offer clarifying questions rather than assuming understanding.]\n\n`
|
|
884
|
+
}
|
|
885
|
+
} catch {
|
|
886
|
+
// Emotion detection is non-critical — fail silently
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── Normal message processing (AI chat) ──
|
|
890
|
+
// First user message: auto-name the session if it still carries the
|
|
891
|
+
// default cwd-basename name (respecting any manual /rename).
|
|
892
|
+
if (
|
|
893
|
+
engine
|
|
894
|
+
.getContext()
|
|
895
|
+
.getMessages()
|
|
896
|
+
.every((m) => m.role !== 'user')
|
|
897
|
+
) {
|
|
898
|
+
const currentName = discoverSessions().find((s) => s.id === sessionId)?.name
|
|
899
|
+
if (isDefaultSessionName(currentName, process.cwd())) {
|
|
900
|
+
const title = deriveSessionTitle(input)
|
|
901
|
+
if (title && sessionId) renameActiveSession(sessionId, title)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
setMessages((prev) => [...prev, { role: 'user', content: input }])
|
|
906
|
+
setIsLoading(true)
|
|
907
|
+
|
|
908
|
+
// Start agent progress indicator immediately for ALL processing
|
|
909
|
+
const progressStart = Date.now()
|
|
910
|
+
const progress: AgentProgress = {
|
|
911
|
+
name: '',
|
|
912
|
+
description: '',
|
|
913
|
+
startTime: progressStart,
|
|
914
|
+
tokensUsed: 0,
|
|
915
|
+
}
|
|
916
|
+
agentProgressRef.current = progress
|
|
917
|
+
setAgentProgress(progress)
|
|
918
|
+
setAgentTick((t) => t + 1)
|
|
919
|
+
|
|
920
|
+
const controller = new AbortController()
|
|
921
|
+
abortRef.current = controller
|
|
922
|
+
|
|
923
|
+
await runTurn(emotionPrefix ? emotionPrefix + input : input, 'user', controller)
|
|
809
924
|
},
|
|
810
|
-
[engine, mkCtx,
|
|
925
|
+
[engine, mkCtx, runTurn],
|
|
811
926
|
)
|
|
812
927
|
|
|
813
928
|
useInput((_input, key) => {
|
|
@@ -902,7 +1017,14 @@ export function App({
|
|
|
902
1017
|
<>
|
|
903
1018
|
{/* Chat panel */}
|
|
904
1019
|
<ChatPanel messages={messages} focusMode={focusMode} />
|
|
905
|
-
{
|
|
1020
|
+
{(() => {
|
|
1021
|
+
const indicator = formatThinking(
|
|
1022
|
+
config.showThinking ?? 'minimal',
|
|
1023
|
+
thinkingText,
|
|
1024
|
+
t('ui.loading.thinking'),
|
|
1025
|
+
)
|
|
1026
|
+
return indicator ? <Text dimColor>{indicator}</Text> : null
|
|
1027
|
+
})()}
|
|
906
1028
|
|
|
907
1029
|
{/* Input with separator lines */}
|
|
908
1030
|
{pickerOpen ? (
|
package/src/ui/commands.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
|
|
|
62
62
|
import { suggestDirectories } from '../commands/cd-suggest.js'
|
|
63
63
|
import { keysCmd } from '../commands/keys'
|
|
64
64
|
import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
|
|
65
|
+
import { listActiveAutoloops, formatLoopRows } from '../commands/autoloop-journal.js'
|
|
65
66
|
import { execSync } from 'node:child_process'
|
|
66
67
|
import { OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
67
68
|
import { renameActiveSession } from '../agent/cross-session/discovery'
|
|
@@ -545,6 +546,7 @@ const configCmd: CommandHandler = (ctx) => {
|
|
|
545
546
|
`defaultProvider: ${c.defaultProvider}`,
|
|
546
547
|
`defaultModel: ${c.defaultModel}`,
|
|
547
548
|
`permission: ${c.permission}`,
|
|
549
|
+
`showThinking: ${c.showThinking ?? 'minimal'}`,
|
|
548
550
|
`providers: ${c.providers.length} configured`,
|
|
549
551
|
'',
|
|
550
552
|
`crossSessionInbound: ${xs.crossSessionInbound}`,
|
|
@@ -2105,6 +2107,12 @@ const usageCmd: CommandHandler = (ctx) => {
|
|
|
2105
2107
|
const toolSection =
|
|
2106
2108
|
toolLines.length > 0 ? `\n${t('commands.usage.tool_section')}\n${toolLines.join('\n')}` : ''
|
|
2107
2109
|
|
|
2110
|
+
const loops = listActiveAutoloops()
|
|
2111
|
+
const loopSection =
|
|
2112
|
+
loops.length > 0
|
|
2113
|
+
? `\n${t('commands.usage.loops_header')}\n${formatLoopRows(loops).join('\n')}`
|
|
2114
|
+
: ''
|
|
2115
|
+
|
|
2108
2116
|
return {
|
|
2109
2117
|
content: stripIndent`
|
|
2110
2118
|
${t('commands.usage.title')}
|
|
@@ -2115,7 +2123,7 @@ const usageCmd: CommandHandler = (ctx) => {
|
|
|
2115
2123
|
${t('commands.usage.model_line', { model: ctx.modelId })}
|
|
2116
2124
|
|
|
2117
2125
|
${'█'.repeat(Math.ceil(Number(pct) / 5))}${'░'.repeat(20 - Math.ceil(Number(pct) / 5))} ${pct}%
|
|
2118
|
-
${toolSection}
|
|
2126
|
+
${toolSection}${loopSection}
|
|
2119
2127
|
|
|
2120
2128
|
${t('commands.usage.hint')}
|
|
2121
2129
|
`,
|
|
@@ -2618,7 +2626,7 @@ function parseInterval(raw: string): number | null {
|
|
|
2618
2626
|
return null
|
|
2619
2627
|
}
|
|
2620
2628
|
|
|
2621
|
-
const loopCmd: CommandHandler = async (
|
|
2629
|
+
const loopCmd: CommandHandler = async (ctx, args) => {
|
|
2622
2630
|
const sub = args[0]
|
|
2623
2631
|
|
|
2624
2632
|
// ── /loop init — scaffold project vault ──
|
|
@@ -2720,7 +2728,7 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
|
|
|
2720
2728
|
|
|
2721
2729
|
const sessionId = `autoloop-${Date.now()}`
|
|
2722
2730
|
const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
|
|
2723
|
-
createAutoloopJournal(sessionId, prompt)
|
|
2731
|
+
createAutoloopJournal(sessionId, prompt, ctx.engine.getUsageTracker().totalApiTokens)
|
|
2724
2732
|
|
|
2725
2733
|
const autoPrompt =
|
|
2726
2734
|
`## Autonomous Loop — ${sessionId}\n\n` +
|
|
@@ -2780,7 +2788,7 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
|
|
|
2780
2788
|
if (fullPrompt.length > 0) {
|
|
2781
2789
|
const sessionId = `autoloop-${Date.now()}`
|
|
2782
2790
|
const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
|
|
2783
|
-
createAutoloopJournal(sessionId, fullPrompt)
|
|
2791
|
+
createAutoloopJournal(sessionId, fullPrompt, ctx.engine.getUsageTracker().totalApiTokens)
|
|
2784
2792
|
|
|
2785
2793
|
const autoPrompt =
|
|
2786
2794
|
`## Autonomous Loop — ${sessionId}\n\n` +
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /loop idle-wakeup folding — collapse consecutive noop ticks into a single
|
|
3
|
+
* "💤 idle ×N" line so repeated "nothing to report" wakeups don't spam the UI.
|
|
4
|
+
*/
|
|
5
|
+
export function collapseNoopTicks(ticks: Array<{ noop: boolean }>): string {
|
|
6
|
+
const lines: string[] = []
|
|
7
|
+
let i = 0
|
|
8
|
+
while (i < ticks.length) {
|
|
9
|
+
const tick = ticks[i]!
|
|
10
|
+
if (tick.noop) {
|
|
11
|
+
let count = 0
|
|
12
|
+
while (i < ticks.length && ticks[i]!.noop) {
|
|
13
|
+
count++
|
|
14
|
+
i++
|
|
15
|
+
}
|
|
16
|
+
lines.push(`💤 idle ×${count}`)
|
|
17
|
+
} else {
|
|
18
|
+
lines.push('● active')
|
|
19
|
+
i++
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return lines.join('\n')
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ThinkingMode = 'off' | 'minimal' | 'full'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render the live reasoning/thinking indicator for the given display mode.
|
|
5
|
+
*
|
|
6
|
+
* - `off` → nothing (hide thinking entirely)
|
|
7
|
+
* - `minimal` → a content-free "💭 <label>…" so long reasoning passes don't
|
|
8
|
+
* look like a stall, without dumping the model's chain-of-thought
|
|
9
|
+
* - `full` → the last 200 chars of the actual thinking text (legacy behavior)
|
|
10
|
+
*
|
|
11
|
+
* Returns null when there is no thinking text to show (or mode is `off`).
|
|
12
|
+
*/
|
|
13
|
+
export function formatThinking(
|
|
14
|
+
mode: ThinkingMode,
|
|
15
|
+
thinkingText: string,
|
|
16
|
+
thinkingLabel: string,
|
|
17
|
+
): string | null {
|
|
18
|
+
if (mode === 'off' || !thinkingText) return null
|
|
19
|
+
if (mode === 'minimal') return `💭 ${thinkingLabel}…`
|
|
20
|
+
return `💭 ${thinkingText.slice(-200)}`
|
|
21
|
+
}
|