@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/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
|
-
|
|
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({
|
|
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
|
@@ -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
|
|
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
|
-
|
|
369
|
+
Or in `~/.mipham/settings.json` as `permissions.defaultMode` (the key Claude Code
|
|
370
|
+
users already have). Any mode name from the table above is accepted in either,
|
|
371
|
+
including `bypassPermissions`.
|
|
372
|
+
|
|
373
|
+
A **project-level** `.mipham/config.yml` cannot set a mode — that file arrives with
|
|
374
|
+
the code, so whoever wrote the repository would be choosing the approval gate. A
|
|
375
|
+
mode found there is ignored and reported; the fix is to move the line to the user
|
|
376
|
+
level, or to pass `--permission <mode>` for one invocation.
|
|
370
377
|
|
|
371
378
|
Or via slash command:
|
|
372
379
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync,
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
|
|
4
5
|
import { miphamHome } from '../core/paths.ts'
|
|
@@ -109,6 +110,6 @@ export class AgentExperience {
|
|
|
109
110
|
},
|
|
110
111
|
)
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
atomicWriteFileSync(this.expFile, content, { mode: 0o644 })
|
|
113
114
|
}
|
|
114
115
|
}
|
|
@@ -83,12 +83,16 @@ export class BackgroundAgentRegistry {
|
|
|
83
83
|
*
|
|
84
84
|
* @param description - Human-readable description
|
|
85
85
|
* @param agentType - Sub-agent type (general, explore, plan, code-review)
|
|
86
|
-
* @param executor - Async function that performs the work
|
|
86
|
+
* @param executor - Async function that performs the work. Given the task's
|
|
87
|
+
* abort signal **and its id**: the id is minted here, and it is the address
|
|
88
|
+
* peers send to (`SendMessage` → `MessageRouter` → this `bg-…`), so an
|
|
89
|
+
* executor that never learns it has no way to be reachable. Callers that do
|
|
90
|
+
* not need it can keep declaring just the signal.
|
|
87
91
|
*/
|
|
88
92
|
spawn(
|
|
89
93
|
description: string,
|
|
90
94
|
agentType: string,
|
|
91
|
-
executor: (signal: AbortSignal) => Promise<string>,
|
|
95
|
+
executor: (signal: AbortSignal, id: string) => Promise<string>,
|
|
92
96
|
kind: BackgroundTaskKind = 'interactive',
|
|
93
97
|
): string {
|
|
94
98
|
const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
|
|
@@ -132,7 +136,7 @@ export class BackgroundAgentRegistry {
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// Execute in background — do NOT await
|
|
135
|
-
executor(task.abortController.signal)
|
|
139
|
+
executor(task.abortController.signal, id)
|
|
136
140
|
.then((result) => {
|
|
137
141
|
task.status = 'completed'
|
|
138
142
|
task.completedAt = new Date()
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
readFileSync,
|
|
4
|
-
writeFileSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
statSync,
|
|
9
|
-
} from 'node:fs'
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync, statSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../../shared/atomic-write'
|
|
10
3
|
import { join, basename } from 'node:path'
|
|
11
4
|
import { hostname } from 'node:os'
|
|
12
5
|
import type { SessionInfo, CrossSessionInbound } from '../../shared/types'
|
|
@@ -25,7 +18,7 @@ const STALE_SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — heartbeat is 30s
|
|
|
25
18
|
export function registerActiveSession(info: SessionInfo): void {
|
|
26
19
|
mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
|
|
27
20
|
const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
|
|
28
|
-
|
|
21
|
+
atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
|
|
29
22
|
}
|
|
30
23
|
|
|
31
24
|
/**
|
|
@@ -36,7 +29,7 @@ export function heartbeatSession(sessionId: string): void {
|
|
|
36
29
|
if (existsSync(filePath)) {
|
|
37
30
|
// Touch the file by rewriting it
|
|
38
31
|
const raw = readFileSync(filePath, 'utf-8')
|
|
39
|
-
|
|
32
|
+
atomicWriteFileSync(filePath, raw, { mode: 0o644 }) // updates mtime
|
|
40
33
|
}
|
|
41
34
|
}
|
|
42
35
|
|
|
@@ -141,7 +134,7 @@ export function renameActiveSession(sessionId: string, newName: string): string
|
|
|
141
134
|
const info = JSON.parse(raw) as SessionInfo
|
|
142
135
|
const others = discoverSessions().filter((s) => s.id !== sessionId)
|
|
143
136
|
info.name = ensureUniqueSessionName(newName, others)
|
|
144
|
-
|
|
137
|
+
atomicWriteFileSync(filePath, JSON.stringify(info, null, 2), { mode: 0o644 })
|
|
145
138
|
return info.name
|
|
146
139
|
}
|
|
147
140
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
mkdirSync,
|
|
3
|
-
writeFileSync,
|
|
4
3
|
readdirSync,
|
|
5
4
|
readFileSync,
|
|
6
5
|
unlinkSync,
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
renameSync,
|
|
9
8
|
lstatSync,
|
|
10
9
|
} from 'node:fs'
|
|
10
|
+
import { atomicWriteFileSync } from '../../shared/atomic-write'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import type { CrossSessionTransport } from './transport'
|
|
13
13
|
import type { AgentMessage } from '../message-bus'
|
|
@@ -69,7 +69,7 @@ export class FileInboxTransport implements CrossSessionTransport {
|
|
|
69
69
|
|
|
70
70
|
// Atomic write: temp file then rename
|
|
71
71
|
const tmpPath = filePath + '.tmp'
|
|
72
|
-
|
|
72
|
+
atomicWriteFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o644 })
|
|
73
73
|
renameSync(tmpPath, filePath)
|
|
74
74
|
|
|
75
75
|
return true
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync,
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
2
3
|
import { join, dirname } from 'node:path'
|
|
3
4
|
import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
|
|
4
5
|
import { isRecoverableToolFailure } from './recoverable-failure.js'
|
|
@@ -174,7 +175,7 @@ export class EffectivenessTracker {
|
|
|
174
175
|
for (const [k, v] of this.data) {
|
|
175
176
|
obj[k] = v
|
|
176
177
|
}
|
|
177
|
-
|
|
178
|
+
atomicWriteFileSync(this.storePath, JSON.stringify(obj, null, 2), { mode: 0o644 })
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
load(): void {
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ProviderRegistry } from '../providers/registry'
|
|
2
2
|
import type { Llm } from '../providers/llm'
|
|
3
|
-
import type { ToolDefinition, ToolContext } from '../shared/index.ts'
|
|
3
|
+
import type { Message, ToolDefinition, ToolContext } from '../shared/index.ts'
|
|
4
4
|
import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
|
|
5
5
|
import { createAgentContext } from './agent-context'
|
|
6
6
|
import { getBackgroundAgentRegistry } from './background-registry'
|
|
7
|
-
import { getMessageBus } from './message-bus'
|
|
7
|
+
import { formatInboundMessage, getMessageBus } from './message-bus'
|
|
8
8
|
import type { HookEngine } from '../core/hooks'
|
|
9
9
|
import { PermissionSystem } from '../core/permission'
|
|
10
|
+
import { buildPermissionBlock } from '../core/instructions'
|
|
10
11
|
import { AgentExperience } from './agent-experience'
|
|
11
12
|
import { PatternAnalyzer } from './pattern-analyzer.js'
|
|
12
13
|
import { getWorkspaceTrust } from '../core/workspace-trust'
|
|
@@ -91,14 +92,14 @@ export class SubAgent {
|
|
|
91
92
|
if (options.runInBackground) {
|
|
92
93
|
const bgRegistry = getBackgroundAgentRegistry()
|
|
93
94
|
|
|
94
|
-
const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
|
|
95
|
+
const taskId = bgRegistry.spawn(description, agentType, async (signal, agentId) => {
|
|
95
96
|
// Run the synchronous execution inside the background executor, reporting
|
|
96
97
|
// cumulative token usage back to the registry for live footer display.
|
|
97
98
|
const opts: SubAgentOptions = {
|
|
98
99
|
...options,
|
|
99
100
|
onTokenUsage: (total) => bgRegistry.updateTokenUsage(taskId, total),
|
|
100
101
|
}
|
|
101
|
-
return this.runExecution(prompt, opts, signal)
|
|
102
|
+
return this.runExecution(prompt, opts, signal, agentId)
|
|
102
103
|
})
|
|
103
104
|
|
|
104
105
|
// Register completion callback for hook firing
|
|
@@ -148,7 +149,10 @@ export class SubAgent {
|
|
|
148
149
|
|
|
149
150
|
// ── Synchronous execution path ──
|
|
150
151
|
try {
|
|
151
|
-
|
|
152
|
+
// A caller that spawned the registry task itself owns the only abort
|
|
153
|
+
// controller for it; without passing it down the run ignores every check
|
|
154
|
+
// inside `runExecution` and cannot be stopped from the dashboard.
|
|
155
|
+
const result = await this.runExecution(prompt, options, options.signal)
|
|
152
156
|
if (this.hookEngine) {
|
|
153
157
|
await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
|
|
154
158
|
}
|
|
@@ -210,10 +214,35 @@ export class SubAgent {
|
|
|
210
214
|
/**
|
|
211
215
|
* Internal execution method — shared by sync and background paths.
|
|
212
216
|
*/
|
|
217
|
+
/**
|
|
218
|
+
* Drain same-process messages addressed to *this* agent into its own turn,
|
|
219
|
+
* mirroring `Engine.drainInboundMessages` for the main session.
|
|
220
|
+
*
|
|
221
|
+
* Only the background path has an address to drain: `bg-…` is the recipient the
|
|
222
|
+
* message router publishes to, and it is minted by the registry — which is why
|
|
223
|
+
* `spawn` hands it to the executor. Until this ran, `SendMessage` to a running
|
|
224
|
+
* background agent returned `success: true, routedTo: 'bus'` and the message was
|
|
225
|
+
* read by nobody: the bus's only reader polled `[sessionId, 'main']`, so what
|
|
226
|
+
* the sender was told had been delivered sat there until the 1-hour prune.
|
|
227
|
+
* (The same hole the max-turns notice advertises away — "Use SendMessage to
|
|
228
|
+
* continue this sub-agent".)
|
|
229
|
+
*/
|
|
230
|
+
private drainInboundMessages(agentId: string | undefined, messages: Message[]): number {
|
|
231
|
+
if (!agentId) return 0
|
|
232
|
+
const bus = getMessageBus()
|
|
233
|
+
const inbound = bus.poll(agentId)
|
|
234
|
+
for (const msg of inbound) {
|
|
235
|
+
messages.push({ role: 'user', content: formatInboundMessage(msg) })
|
|
236
|
+
}
|
|
237
|
+
if (inbound.length > 0) bus.markAllRead(agentId)
|
|
238
|
+
return inbound.length
|
|
239
|
+
}
|
|
240
|
+
|
|
213
241
|
private async runExecution(
|
|
214
242
|
prompt: string,
|
|
215
243
|
options: SubAgentOptions,
|
|
216
244
|
signal?: AbortSignal,
|
|
245
|
+
agentId?: string,
|
|
217
246
|
): Promise<string> {
|
|
218
247
|
if (!this.registry.getActive()) {
|
|
219
248
|
throw new Error('No active provider available for sub-agent execution')
|
|
@@ -360,7 +389,24 @@ export class SubAgent {
|
|
|
360
389
|
let hitMaxTurns = false
|
|
361
390
|
|
|
362
391
|
let currentMessages = messages
|
|
363
|
-
|
|
392
|
+
// ── 子代理报**它自己的档**,不是父档 ──
|
|
393
|
+
// `gate` 是每个工具调用真正过的那个闸门(上面构造的那个,缺省是 `default`);
|
|
394
|
+
// 闸门的模式取自 `createSubAgentPermission` 的**解析结果** —— 定义写了
|
|
395
|
+
// `permissionMode` 的就是它自己那一档,写 `inherit` 的才是父档 —— 这里只读结果、
|
|
396
|
+
// 不重算,免得同一个事实有第二份推导。读 `getMode()` 而不是定义里那个字符串:
|
|
397
|
+
// 组织级上限(`maxAllowedMode`/`forbiddenModes`)会静默把模式钳走,报请求档就是
|
|
398
|
+
// 报得比实际宽 —— 与系统提示那边(`index.tsx` 的接线行)同一个理由。
|
|
399
|
+
//
|
|
400
|
+
// 它烘在**这一处**而不是走 `ContextManager.setPermissionContextSource`:子代理的请求
|
|
401
|
+
// 读的是下面这个局部变量(`systemPrompt: currentSystemPrompt`),**从不读上下文**的
|
|
402
|
+
// 系统提示 —— 挂在上下文上会是一处装饰(有接线、请求里一个字都到不了),正是本仓库
|
|
403
|
+
// 反复收的那种账。同样因为后续回合把提示清成 `''`(`currentSystemPrompt = ''`,
|
|
404
|
+
// 首轮才带系统提示),
|
|
405
|
+
// 权限段随**唯一**带提示的那一轮走,而不是每轮派生。
|
|
406
|
+
const permissionBlock = buildPermissionBlock(gate.getMode())
|
|
407
|
+
let currentSystemPrompt = permissionBlock
|
|
408
|
+
? `${systemPrompt}\n\n---\n\n${permissionBlock}`
|
|
409
|
+
: systemPrompt
|
|
364
410
|
let totalTokens = 0
|
|
365
411
|
|
|
366
412
|
// Context for the sub-agent's own tool calls. Built once per run: the caller's
|
|
@@ -391,6 +437,10 @@ export class SubAgent {
|
|
|
391
437
|
throw new DOMException('Aborted', 'AbortError')
|
|
392
438
|
}
|
|
393
439
|
|
|
440
|
+
// Per turn, not once per run: a peer can write while this agent is mid
|
|
441
|
+
// task, and the point of the channel is to steer the work in progress.
|
|
442
|
+
this.drainInboundMessages(agentId, currentMessages)
|
|
443
|
+
|
|
394
444
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
395
445
|
let turnText = ''
|
|
396
446
|
|
|
@@ -506,6 +556,12 @@ export class SubAgent {
|
|
|
506
556
|
// `createSubAgentPermission`), so the default sub-agent is unchanged.
|
|
507
557
|
const decision = await gate.resolveApproval(tool, effectiveInput, { signal })
|
|
508
558
|
if (decision.level === 'ask') {
|
|
559
|
+
// Same circuit breaker as `Engine.executeTool`, sharing its threshold:
|
|
560
|
+
// after this many refusals in a row, say so, so the model stops
|
|
561
|
+
// re-issuing the call. It matters more here — a sub-agent gets five
|
|
562
|
+
// turns and cannot ask anyone, so a silent retry loop spends the whole
|
|
563
|
+
// run on a route that is closed to it.
|
|
564
|
+
const limitExceeded = gate.incrementBlockCounter()
|
|
509
565
|
currentMessages.push({
|
|
510
566
|
role: 'user' as const,
|
|
511
567
|
content:
|
|
@@ -513,10 +569,15 @@ export class SubAgent {
|
|
|
513
569
|
`Cannot execute in non-interactive sub-agent context.` +
|
|
514
570
|
(decision.source === 'classifier' && decision.classifierReason
|
|
515
571
|
? ` Classifier: ${decision.classifierReason}`
|
|
572
|
+
: '') +
|
|
573
|
+
(limitExceeded
|
|
574
|
+
? '\n(Consecutive block limit reached. This route stays closed for the rest of this run — try a different approach instead of retrying this call.)'
|
|
516
575
|
: ''),
|
|
517
576
|
})
|
|
518
577
|
continue
|
|
519
578
|
}
|
|
579
|
+
// Allowed — the streak is broken (mirrors the engine's reset).
|
|
580
|
+
gate.resetBlockCounter()
|
|
520
581
|
|
|
521
582
|
try {
|
|
522
583
|
const result = await tool.execute(effectiveInput, toolContext)
|
package/src/agent/types.ts
CHANGED
|
@@ -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
|
|