@miphamai/cli 0.14.0 → 0.16.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/agent/message-bus.ts +13 -1
- package/src/agent/sub-agent.ts +37 -3
- package/src/agent/types.ts +2 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/preferences.ts +45 -0
- package/src/core/context.ts +9 -0
- package/src/core/engine.ts +52 -1
- package/src/core/permission-config.ts +70 -6
- package/src/core/permission.ts +50 -9
- package/src/core/session-store.ts +4 -2
- package/src/core/usage-tracker.ts +103 -0
- package/src/index.tsx +50 -2
- package/src/providers/anthropic.ts +9 -1
- package/src/providers/openai-compat.ts +10 -0
- package/src/providers/registry.ts +8 -0
- package/src/shared/sanitize.ts +38 -0
- package/src/shared/types.ts +32 -1
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/registry.ts +69 -2
- package/src/tools/agent/agent.ts +1 -1
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/exec/bash.ts +37 -5
- package/src/tools/index.ts +5 -2
- package/src/ui/app.tsx +16 -3
- package/src/ui/commands.ts +68 -56
- package/src/workflow/primitives/agent.ts +132 -46
- package/src/workflow/runtime.ts +24 -37
- package/src/workflow/sandbox.ts +22 -10
package/package.json
CHANGED
package/src/agent/message-bus.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* const unread = bus.poll('bg-1') // messages addressed TO bg-1
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
export type AgentMessageType = 'message' | 'warning' | 'error'
|
|
15
|
+
|
|
14
16
|
export interface AgentMessage {
|
|
15
17
|
id: string
|
|
16
18
|
from: string
|
|
@@ -19,6 +21,7 @@ export interface AgentMessage {
|
|
|
19
21
|
message: string
|
|
20
22
|
timestamp: Date
|
|
21
23
|
read: boolean
|
|
24
|
+
type: AgentMessageType
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export class AgentMessageBus {
|
|
@@ -29,7 +32,7 @@ export class AgentMessageBus {
|
|
|
29
32
|
* Post a message from one agent to another.
|
|
30
33
|
* Returns the message ID.
|
|
31
34
|
*/
|
|
32
|
-
post(from: string, to: string, summary: string, message: string): string {
|
|
35
|
+
post(from: string, to: string, summary: string, message: string, type: AgentMessageType = 'message'): string {
|
|
33
36
|
const id = `msg-${++this.idCounter}`
|
|
34
37
|
this.messages.push({
|
|
35
38
|
id,
|
|
@@ -39,6 +42,7 @@ export class AgentMessageBus {
|
|
|
39
42
|
message,
|
|
40
43
|
timestamp: new Date(),
|
|
41
44
|
read: false,
|
|
45
|
+
type,
|
|
42
46
|
})
|
|
43
47
|
return id
|
|
44
48
|
}
|
|
@@ -91,6 +95,14 @@ export class AgentMessageBus {
|
|
|
91
95
|
return this.messages.filter((m) => m.to === agentId && !m.read).length
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Get all unread warning messages for an agent.
|
|
100
|
+
* Does NOT mark them as read — use markRead() for that.
|
|
101
|
+
*/
|
|
102
|
+
getWarnings(agentId: string): AgentMessage[] {
|
|
103
|
+
return this.messages.filter((m) => m.to === agentId && m.type === 'warning' && !m.read)
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
/**
|
|
95
107
|
* Prune messages older than maxAgeMs. Returns the number pruned.
|
|
96
108
|
*/
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { ToolDefinition } from '../shared/index.ts'
|
|
|
3
3
|
import type { SubAgentType, SubAgentOptions, AgentDefinition } from './types'
|
|
4
4
|
import { createAgentContext } from './agent-context'
|
|
5
5
|
import { getBackgroundAgentRegistry } from './background-registry'
|
|
6
|
+
import { getMessageBus } from './message-bus'
|
|
6
7
|
import type { HookEngine } from '../core/hooks'
|
|
8
|
+
import type { PermissionSystem } from '../core/permission'
|
|
7
9
|
|
|
8
10
|
const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
|
|
9
11
|
general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
|
|
@@ -27,6 +29,7 @@ export class SubAgent {
|
|
|
27
29
|
constructor(
|
|
28
30
|
private registry: ProviderRegistry,
|
|
29
31
|
private toolRegistry: Map<string, ToolDefinition>,
|
|
32
|
+
private permission?: PermissionSystem,
|
|
30
33
|
private hookEngine?: HookEngine,
|
|
31
34
|
) {}
|
|
32
35
|
|
|
@@ -113,6 +116,9 @@ export class SubAgent {
|
|
|
113
116
|
const agentType = options.type || 'general'
|
|
114
117
|
const agentDef = options.agentDef
|
|
115
118
|
|
|
119
|
+
// Resolve execution directory: worktree isolation or process cwd
|
|
120
|
+
const execCwd = options.worktreePath || process.cwd()
|
|
121
|
+
|
|
116
122
|
// Resolve system prompt: agentDef > options.systemPrompt > builtin type
|
|
117
123
|
const systemPrompt =
|
|
118
124
|
agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[agentType]
|
|
@@ -151,6 +157,22 @@ export class SubAgent {
|
|
|
151
157
|
// 'inherit' means use parent model
|
|
152
158
|
const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
|
|
153
159
|
|
|
160
|
+
// Validate resolved model exists in registry; fall back to parent model with warning
|
|
161
|
+
let finalModel = resolvedModel
|
|
162
|
+
if (resolvedModel !== model) {
|
|
163
|
+
const modelExists = this.registry.findModel(resolvedModel) !== undefined
|
|
164
|
+
if (!modelExists) {
|
|
165
|
+
const warnMsg = `Warning: model "${resolvedModel}" not found in provider registry. Falling back to "${model}".`
|
|
166
|
+
console.warn(warnMsg)
|
|
167
|
+
|
|
168
|
+
// Post warning to message bus for UI display
|
|
169
|
+
const bus = getMessageBus()
|
|
170
|
+
bus.post('system', 'main', `Sub-agent model fallback: ${resolvedModel} → ${model}`, warnMsg, 'warning')
|
|
171
|
+
|
|
172
|
+
finalModel = model
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
154
176
|
const chunks: string[] = []
|
|
155
177
|
const MAX_TOOL_TURNS = options.maxTurns || 5
|
|
156
178
|
|
|
@@ -168,7 +190,7 @@ export class SubAgent {
|
|
|
168
190
|
let turnText = ''
|
|
169
191
|
|
|
170
192
|
for await (const chunk of provider.chat({
|
|
171
|
-
model:
|
|
193
|
+
model: finalModel,
|
|
172
194
|
messages: currentMessages,
|
|
173
195
|
systemPrompt: currentSystemPrompt,
|
|
174
196
|
tools: toolDefs,
|
|
@@ -229,12 +251,24 @@ export class SubAgent {
|
|
|
229
251
|
continue
|
|
230
252
|
}
|
|
231
253
|
|
|
254
|
+
// Security: check permission before executing
|
|
255
|
+
// Sub-agents run without user interaction — tools requiring approval are rejected
|
|
256
|
+
if (this.permission?.needsApproval(tool, tu.input)) {
|
|
257
|
+
currentMessages.push({
|
|
258
|
+
role: 'user' as const,
|
|
259
|
+
content:
|
|
260
|
+
`Tool "${tu.name}" requires user approval (permission: ask). ` +
|
|
261
|
+
`Cannot execute in non-interactive sub-agent context.`,
|
|
262
|
+
})
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
232
266
|
try {
|
|
233
267
|
const result = await tool.execute(tu.input, {
|
|
234
|
-
cwd:
|
|
268
|
+
cwd: execCwd,
|
|
235
269
|
sessionId: 'sub-agent',
|
|
236
270
|
provider: '',
|
|
237
|
-
model:
|
|
271
|
+
model: finalModel,
|
|
238
272
|
})
|
|
239
273
|
|
|
240
274
|
currentMessages.push({
|
package/src/agent/types.ts
CHANGED
|
@@ -44,4 +44,6 @@ export interface SubAgentOptions {
|
|
|
44
44
|
runInBackground?: boolean
|
|
45
45
|
/** Optional callback for streaming progress chunks during background execution. */
|
|
46
46
|
onProgress?: (chunk: string) => void
|
|
47
|
+
/** When set, tool executions use this path as cwd (git worktree isolation). */
|
|
48
|
+
worktreePath?: string
|
|
47
49
|
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -13,6 +13,10 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
13
13
|
defaultModel: 'deepseek-v4-pro',
|
|
14
14
|
permission: 'auto',
|
|
15
15
|
providers: DEFAULT_PROVIDERS,
|
|
16
|
+
marketplace: {
|
|
17
|
+
strictKnownMarketplaces: [],
|
|
18
|
+
blockedMarketplaces: [],
|
|
19
|
+
},
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export const DEFAULT_INFERENCE_HOOK_CONFIG: InferenceHookConfig = {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight preferences store backed by ~/.mipham/preferences.json.
|
|
3
|
+
* Used for persisting user-level UI state (e.g. last code review effort).
|
|
4
|
+
*
|
|
5
|
+
* NOT for config.yml settings — those belong in the YAML config system.
|
|
6
|
+
* NOT for secrets — this file is plain JSON, not encrypted.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
|
|
12
|
+
const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
|
|
13
|
+
|
|
14
|
+
function readPrefs(): Record<string, string> {
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(PREFS_PATH)) return {}
|
|
17
|
+
const raw = readFileSync(PREFS_PATH, 'utf-8')
|
|
18
|
+
const parsed: unknown = JSON.parse(raw)
|
|
19
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
|
20
|
+
return parsed as Record<string, string>
|
|
21
|
+
} catch {
|
|
22
|
+
return {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writePrefs(prefs: Record<string, string>): void {
|
|
27
|
+
try {
|
|
28
|
+
const dir = join(homedir(), '.mipham')
|
|
29
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
30
|
+
writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600, encoding: 'utf-8' })
|
|
31
|
+
} catch {
|
|
32
|
+
// best-effort; never crash because preferences failed to save
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getPreference(key: string, defaultValue: string): string {
|
|
37
|
+
const prefs = readPrefs()
|
|
38
|
+
return prefs[key] ?? defaultValue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setPreference(key: string, value: string): void {
|
|
42
|
+
const prefs = readPrefs()
|
|
43
|
+
prefs[key] = value
|
|
44
|
+
writePrefs(prefs)
|
|
45
|
+
}
|
package/src/core/context.ts
CHANGED
|
@@ -55,6 +55,15 @@ export class ContextManager {
|
|
|
55
55
|
|
|
56
56
|
constructor(private config: ContextConfig) {}
|
|
57
57
|
|
|
58
|
+
/** Dynamically update the max token limit (e.g., when switching models). */
|
|
59
|
+
updateMaxTokens(maxTokens: number): void {
|
|
60
|
+
this.config.maxTokens = maxTokens
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getMaxTokens(): number {
|
|
64
|
+
return this.config.maxTokens
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
/** Set an optional LLM summarizer for intelligent compaction. */
|
|
59
68
|
setSummarizer(fn: Summarizer): void {
|
|
60
69
|
this.summarizer = fn
|
package/src/core/engine.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
|
16
16
|
import type { SkillsLoader } from '../skills/loader'
|
|
17
17
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
18
18
|
import { RulesLoader } from './rules-loader'
|
|
19
|
+
import { UsageTracker } from './usage-tracker'
|
|
19
20
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
20
21
|
|
|
21
22
|
export class QueryEngine {
|
|
@@ -40,7 +41,7 @@ export class QueryEngine {
|
|
|
40
41
|
private registry: ProviderRegistry,
|
|
41
42
|
private context: ContextManager,
|
|
42
43
|
private tools: Map<string, ToolDefinition>,
|
|
43
|
-
private permission: PermissionSystem = new PermissionSystem('
|
|
44
|
+
private permission: PermissionSystem = new PermissionSystem('default'),
|
|
44
45
|
) {}
|
|
45
46
|
|
|
46
47
|
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
@@ -82,6 +83,7 @@ export class QueryEngine {
|
|
|
82
83
|
private rulesLoader?: RulesLoader
|
|
83
84
|
/** Files touched in the current turn (for rules matching). */
|
|
84
85
|
private touchedFiles: Set<string> = new Set()
|
|
86
|
+
private usageTracker = new UsageTracker()
|
|
85
87
|
/** Inference hook (DLP) configuration. */
|
|
86
88
|
private inferenceHookConfig?: InferenceHookConfig
|
|
87
89
|
|
|
@@ -238,6 +240,10 @@ export class QueryEngine {
|
|
|
238
240
|
return this.permission
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
getUsageTracker(): UsageTracker {
|
|
244
|
+
return this.usageTracker
|
|
245
|
+
}
|
|
246
|
+
|
|
241
247
|
async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
242
248
|
// Fire UserPromptSubmit hooks before processing
|
|
243
249
|
if (this.hookEngine) {
|
|
@@ -293,6 +299,8 @@ export class QueryEngine {
|
|
|
293
299
|
let assistantContent = ''
|
|
294
300
|
let reasoningContent = ''
|
|
295
301
|
let thinkingContent = ''
|
|
302
|
+
let turnApiInputTokens = 0
|
|
303
|
+
let turnApiOutputTokens = 0
|
|
296
304
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
297
305
|
|
|
298
306
|
// Stream model response
|
|
@@ -331,6 +339,12 @@ export class QueryEngine {
|
|
|
331
339
|
})
|
|
332
340
|
}
|
|
333
341
|
|
|
342
|
+
if (chunk.type === 'usage' && chunk.inputTokens !== undefined) {
|
|
343
|
+
// Accumulate API-reported token counts for this turn
|
|
344
|
+
turnApiInputTokens += chunk.inputTokens
|
|
345
|
+
turnApiOutputTokens += chunk.outputTokens || 0
|
|
346
|
+
}
|
|
347
|
+
|
|
334
348
|
if (chunk.type === 'stop') {
|
|
335
349
|
// Add assistant response to context
|
|
336
350
|
if (assistantContent || reasoningContent || thinkingContent) {
|
|
@@ -417,6 +431,31 @@ export class QueryEngine {
|
|
|
417
431
|
})
|
|
418
432
|
}
|
|
419
433
|
|
|
434
|
+
// Record API token usage for this turn, attributed to executed tools
|
|
435
|
+
if (turnApiInputTokens > 0 || turnApiOutputTokens > 0) {
|
|
436
|
+
if (toolUses.length > 0) {
|
|
437
|
+
// Attribute tokens equally across all tools invoked this turn
|
|
438
|
+
const perTool = toolUses.length
|
|
439
|
+
for (const tu of toolUses) {
|
|
440
|
+
this.usageTracker.recordApiUsage(
|
|
441
|
+
Math.round(turnApiInputTokens / perTool),
|
|
442
|
+
Math.round(turnApiOutputTokens / perTool),
|
|
443
|
+
tu.name,
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
this.usageTracker.recordApiUsage(turnApiInputTokens, turnApiOutputTokens, 'chat')
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
// Fallback: API doesn't report usage — use char-based estimate
|
|
451
|
+
const estimated = Math.round((assistantContent.length + userInput.length) / 4)
|
|
452
|
+
if (toolUses.length > 0) {
|
|
453
|
+
for (const tu of toolUses) {
|
|
454
|
+
this.usageTracker.recordEstimatedUsage(Math.round(estimated / toolUses.length), tu.name)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
420
459
|
// Inject path-scoped rules for touched files
|
|
421
460
|
this.injectRules()
|
|
422
461
|
|
|
@@ -721,6 +760,7 @@ export class QueryEngine {
|
|
|
721
760
|
artifactServer: this.artifactServer,
|
|
722
761
|
agentRegistry: this.agentRegistry,
|
|
723
762
|
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
763
|
+
permissionSystem: this.permission,
|
|
724
764
|
})
|
|
725
765
|
|
|
726
766
|
// Track touched files for rules matching
|
|
@@ -781,6 +821,17 @@ export class QueryEngine {
|
|
|
781
821
|
|
|
782
822
|
switchProvider(providerId: string, modelId?: string): void {
|
|
783
823
|
this.registry.switchProvider(providerId, modelId)
|
|
824
|
+
// Update context manager's max tokens to match the new model's context window
|
|
825
|
+
if (modelId) {
|
|
826
|
+
const model = this.registry.findModel(modelId)
|
|
827
|
+
if (model) {
|
|
828
|
+
const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
|
|
829
|
+
const maxTokens = (DISABLE_1M && model.contextWindow > 200_000)
|
|
830
|
+
? 200_000
|
|
831
|
+
: model.contextWindow
|
|
832
|
+
this.context.updateMaxTokens(maxTokens)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
784
835
|
}
|
|
785
836
|
|
|
786
837
|
/** Wrap context compaction with PreCompact/PostCompact hooks. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionConfig, PermissionMode } from '../shared/index.ts'
|
|
1
|
+
import type { PermissionConfig, PermissionMode, PermissionRestrictions } from '../shared/index.ts'
|
|
2
2
|
|
|
3
3
|
const DEFAULT_CONFIG: PermissionConfig = {
|
|
4
4
|
mode: 'default',
|
|
@@ -15,11 +15,15 @@ export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): Permi
|
|
|
15
15
|
mode: (raw.mode as PermissionMode) || DEFAULT_CONFIG.mode,
|
|
16
16
|
allow: Array.isArray(raw.allow) ? raw.allow : [...DEFAULT_CONFIG.allow],
|
|
17
17
|
deny: Array.isArray(raw.deny) ? raw.deny : [...DEFAULT_CONFIG.deny],
|
|
18
|
+
restrictions: raw.restrictions ?? undefined,
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Permission modes ordered from least to most permissive.
|
|
24
|
+
* Used to enforce maxAllowedMode: any mode ranked higher than the cap is forbidden.
|
|
25
|
+
*/
|
|
26
|
+
export const PERMISSION_MODE_HIERARCHY: PermissionMode[] = [
|
|
23
27
|
'default',
|
|
24
28
|
'acceptEdits',
|
|
25
29
|
'plan',
|
|
@@ -28,7 +32,67 @@ export const MODE_CYCLE: PermissionMode[] = [
|
|
|
28
32
|
'bypassPermissions',
|
|
29
33
|
]
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
/** Valid mode transition order for Shift+Tab cycling. */
|
|
36
|
+
export const MODE_CYCLE: PermissionMode[] = [...PERMISSION_MODE_HIERARCHY]
|
|
37
|
+
|
|
38
|
+
/** Resolve which modes are actually permitted given the restrictions. */
|
|
39
|
+
export function getAllowedModes(restrictions?: PermissionRestrictions): PermissionMode[] {
|
|
40
|
+
let allowed = [...MODE_CYCLE]
|
|
41
|
+
|
|
42
|
+
if (restrictions?.forbiddenModes && restrictions.forbiddenModes.length > 0) {
|
|
43
|
+
const forbidden = new Set(restrictions.forbiddenModes)
|
|
44
|
+
allowed = allowed.filter((m) => !forbidden.has(m))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (restrictions?.maxAllowedMode) {
|
|
48
|
+
const capIdx = PERMISSION_MODE_HIERARCHY.indexOf(restrictions.maxAllowedMode)
|
|
49
|
+
if (capIdx >= 0) {
|
|
50
|
+
allowed = allowed.filter((m) => PERMISSION_MODE_HIERARCHY.indexOf(m) <= capIdx)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return allowed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check whether a given mode is permitted under the restrictions. */
|
|
58
|
+
export function isModeAllowed(
|
|
59
|
+
mode: PermissionMode,
|
|
60
|
+
restrictions?: PermissionRestrictions,
|
|
61
|
+
): boolean {
|
|
62
|
+
return getAllowedModes(restrictions).includes(mode)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return the highest allowed mode at or below `desired` given the restrictions.
|
|
67
|
+
* Used to silently downgrade when a forbidden mode is requested.
|
|
68
|
+
*/
|
|
69
|
+
export function clampMode(
|
|
70
|
+
desired: PermissionMode,
|
|
71
|
+
restrictions?: PermissionRestrictions,
|
|
72
|
+
): PermissionMode {
|
|
73
|
+
const allowed = getAllowedModes(restrictions)
|
|
74
|
+
if (allowed.includes(desired)) return desired
|
|
75
|
+
|
|
76
|
+
// Walk downward through the hierarchy to find the closest allowed mode
|
|
77
|
+
const desiredIdx = PERMISSION_MODE_HIERARCHY.indexOf(desired)
|
|
78
|
+
for (let i = desiredIdx - 1; i >= 0; i--) {
|
|
79
|
+
const candidate = PERMISSION_MODE_HIERARCHY[i]!
|
|
80
|
+
if (allowed.includes(candidate)) return candidate
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: return the first allowed mode (should always be at least 'default')
|
|
84
|
+
return allowed[0] ?? 'default'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function nextMode(
|
|
88
|
+
current: PermissionMode,
|
|
89
|
+
restrictions?: PermissionRestrictions,
|
|
90
|
+
): PermissionMode {
|
|
91
|
+
const allowed = getAllowedModes(restrictions)
|
|
92
|
+
const idx = allowed.indexOf(current)
|
|
93
|
+
if (idx === -1) {
|
|
94
|
+
// Current mode is not in the allowed set — clamp then find next
|
|
95
|
+
return clampMode(current, restrictions)
|
|
96
|
+
}
|
|
97
|
+
return allowed[(idx + 1) % allowed.length]!
|
|
34
98
|
}
|
package/src/core/permission.ts
CHANGED
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
PermissionMode,
|
|
4
4
|
PermissionLevel,
|
|
5
5
|
PermissionRule,
|
|
6
|
+
PermissionRestrictions,
|
|
6
7
|
} from '../shared/index.ts'
|
|
7
8
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
8
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
9
|
-
import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
|
|
10
|
+
import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
|
|
10
11
|
|
|
11
12
|
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
12
13
|
|
|
@@ -25,6 +26,9 @@ export class PermissionSystem {
|
|
|
25
26
|
private checkCache = new Map<string, PermissionLevel>()
|
|
26
27
|
private cacheMode: PermissionMode | null = null
|
|
27
28
|
|
|
29
|
+
// ── Org-level restrictions (P0 security) ──
|
|
30
|
+
private restrictions: PermissionRestrictions | undefined = undefined
|
|
31
|
+
|
|
28
32
|
/** Invalidate the permission cache (called on any rule/mode change). */
|
|
29
33
|
private invalidateCache(): void {
|
|
30
34
|
this.checkCache.clear()
|
|
@@ -44,7 +48,7 @@ export class PermissionSystem {
|
|
|
44
48
|
// ── Mode management ──
|
|
45
49
|
|
|
46
50
|
setMode(mode: PermissionMode): void {
|
|
47
|
-
this.mode = mode
|
|
51
|
+
this.mode = clampMode(mode, this.restrictions)
|
|
48
52
|
this.invalidateCache()
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -53,10 +57,26 @@ export class PermissionSystem {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
cycleMode(): PermissionMode {
|
|
56
|
-
this.mode = nextMode(this.mode)
|
|
60
|
+
this.mode = nextMode(this.mode, this.restrictions)
|
|
57
61
|
return this.mode
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
// ── Restrictions (P0: org-level policy gap) ──
|
|
65
|
+
|
|
66
|
+
/** Apply org-level permission restrictions. Overwrites any previous restrictions. */
|
|
67
|
+
setRestrictions(restrictions: PermissionRestrictions | undefined): void {
|
|
68
|
+
this.restrictions = restrictions
|
|
69
|
+
// Re-clamp current mode against new restrictions
|
|
70
|
+
if (restrictions) {
|
|
71
|
+
this.mode = clampMode(this.mode, restrictions)
|
|
72
|
+
}
|
|
73
|
+
this.invalidateCache()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getRestrictions(): PermissionRestrictions | undefined {
|
|
77
|
+
return this.restrictions
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
// ── Rule management ──
|
|
61
81
|
|
|
62
82
|
allow(rule: string): void {
|
|
@@ -71,11 +91,21 @@ export class PermissionSystem {
|
|
|
71
91
|
this.askRules.push(compileRule(rule, 'ask'))
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
loadConfig(raw: {
|
|
94
|
+
loadConfig(raw: {
|
|
95
|
+
mode?: string
|
|
96
|
+
allow?: string[]
|
|
97
|
+
deny?: string[]
|
|
98
|
+
restrictions?: PermissionRestrictions
|
|
99
|
+
}): void {
|
|
75
100
|
const config = loadPermissionConfig(
|
|
76
|
-
raw as Partial<{
|
|
101
|
+
raw as Partial<{
|
|
102
|
+
mode: PermissionMode
|
|
103
|
+
allow: string[]
|
|
104
|
+
deny: string[]
|
|
105
|
+
restrictions: PermissionRestrictions
|
|
106
|
+
}>,
|
|
77
107
|
)
|
|
78
|
-
this.mode = config.mode
|
|
108
|
+
this.mode = clampMode(config.mode, config.restrictions ?? this.restrictions)
|
|
79
109
|
|
|
80
110
|
this.allowRules = []
|
|
81
111
|
this.denyRules = []
|
|
@@ -88,6 +118,10 @@ export class PermissionSystem {
|
|
|
88
118
|
this.denyRules.push(compileRule(rule, 'deny'))
|
|
89
119
|
}
|
|
90
120
|
|
|
121
|
+
if (config.restrictions) {
|
|
122
|
+
this.restrictions = config.restrictions
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
this.invalidateCache()
|
|
92
126
|
}
|
|
93
127
|
|
|
@@ -223,6 +257,11 @@ export class PermissionSystem {
|
|
|
223
257
|
case 'auto':
|
|
224
258
|
// Safety checks handled by hook layer (PreToolUse hooks).
|
|
225
259
|
// Bypass the static permission system so hooks are the sole gate.
|
|
260
|
+
// Exception: SendMessage always goes through the permission classifier
|
|
261
|
+
// so deny/allow rules are honored for cross-session messages.
|
|
262
|
+
if (tool.name === 'SendMessage') {
|
|
263
|
+
return 'mode-baseline'
|
|
264
|
+
}
|
|
226
265
|
return 'bypass'
|
|
227
266
|
|
|
228
267
|
case 'dontAsk':
|
|
@@ -241,9 +280,11 @@ export class PermissionSystem {
|
|
|
241
280
|
|
|
242
281
|
setDefaultLevel(level: PermissionLevel): void {
|
|
243
282
|
// Map legacy 3-level to new mode
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
else
|
|
283
|
+
let newMode: PermissionMode
|
|
284
|
+
if (level === 'auto') newMode = 'auto'
|
|
285
|
+
else if (level === 'bypass') newMode = 'bypassPermissions'
|
|
286
|
+
else newMode = 'default'
|
|
287
|
+
this.mode = clampMode(newMode, this.restrictions)
|
|
247
288
|
this.invalidateCache()
|
|
248
289
|
}
|
|
249
290
|
|
|
@@ -17,6 +17,7 @@ export interface SessionMetadata {
|
|
|
17
17
|
provider: string
|
|
18
18
|
model: string
|
|
19
19
|
messageCount: number
|
|
20
|
+
cwd?: string
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface StoredSession {
|
|
@@ -44,7 +45,7 @@ export class SessionStore {
|
|
|
44
45
|
static save(
|
|
45
46
|
name: string,
|
|
46
47
|
messages: Message[],
|
|
47
|
-
metadata?: { provider?: string; model?: string },
|
|
48
|
+
metadata?: { provider?: string; model?: string; cwd?: string },
|
|
48
49
|
): void {
|
|
49
50
|
ensureDir()
|
|
50
51
|
const path = filePath(name)
|
|
@@ -57,6 +58,7 @@ export class SessionStore {
|
|
|
57
58
|
provider: metadata?.provider || 'unknown',
|
|
58
59
|
model: metadata?.model || 'unknown',
|
|
59
60
|
messageCount: messages.length,
|
|
61
|
+
cwd: metadata?.cwd,
|
|
60
62
|
},
|
|
61
63
|
messages,
|
|
62
64
|
}
|
|
@@ -132,7 +134,7 @@ export class SessionStore {
|
|
|
132
134
|
/**
|
|
133
135
|
* Auto-save with timestamp-based name.
|
|
134
136
|
*/
|
|
135
|
-
static autoSave(messages: Message[], metadata?: { provider?: string; model?: string }): string {
|
|
137
|
+
static autoSave(messages: Message[], metadata?: { provider?: string; model?: string; cwd?: string }): string {
|
|
136
138
|
const name = `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
|
|
137
139
|
SessionStore.save(name, messages, metadata)
|
|
138
140
|
return name
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsageTracker — per-session token usage tracking with per-tool attribution.
|
|
3
|
+
*
|
|
4
|
+
* Tracks actual API-reported token counts (when available) and falls back
|
|
5
|
+
* to character-estimated counts. Token costs are attributed to the tool
|
|
6
|
+
* invoked in each turn — MCP tools (prefixed `mcp__`) get correct
|
|
7
|
+
* per-invocation attribution, not inflated cumulative costs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ToolUsage {
|
|
11
|
+
/** Total input tokens attributed to this tool. */
|
|
12
|
+
inputTokens: number
|
|
13
|
+
/** Total output tokens attributed to this tool. */
|
|
14
|
+
outputTokens: number
|
|
15
|
+
/** Number of times this tool was invoked. */
|
|
16
|
+
calls: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageSummary {
|
|
20
|
+
/** Total input tokens from API (0 if API data unavailable). */
|
|
21
|
+
apiInputTokens: number
|
|
22
|
+
/** Total output tokens from API (0 if API data unavailable). */
|
|
23
|
+
apiOutputTokens: number
|
|
24
|
+
/** Estimated total tokens (chars/4 heuristic, always available). */
|
|
25
|
+
estimatedTokens: number
|
|
26
|
+
/** Per-tool breakdown, keyed by tool name. "chat" = text-only turns. */
|
|
27
|
+
tools: Record<string, ToolUsage>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class UsageTracker {
|
|
31
|
+
private _apiInputTokens = 0
|
|
32
|
+
private _apiOutputTokens = 0
|
|
33
|
+
private _estimatedTokens = 0
|
|
34
|
+
private _toolUsage = new Map<string, ToolUsage>()
|
|
35
|
+
|
|
36
|
+
/** Record API-reported token usage for a turn, attributed to the given tool. */
|
|
37
|
+
recordApiUsage(inputTokens: number, outputTokens: number, toolName?: string): void {
|
|
38
|
+
this._apiInputTokens += inputTokens
|
|
39
|
+
this._apiOutputTokens += outputTokens
|
|
40
|
+
this._recordTool(toolName || 'chat', inputTokens, outputTokens)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Record character-estimated token usage for a turn. */
|
|
44
|
+
recordEstimatedUsage(tokenCount: number, toolName?: string): void {
|
|
45
|
+
this._estimatedTokens += tokenCount
|
|
46
|
+
this._recordToolEstimated(toolName || 'chat', tokenCount)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get a summary of all usage for the current session. */
|
|
50
|
+
getSummary(): UsageSummary {
|
|
51
|
+
const tools: Record<string, ToolUsage> = {}
|
|
52
|
+
for (const [name, usage] of this._toolUsage) {
|
|
53
|
+
tools[name] = { ...usage }
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
apiInputTokens: this._apiInputTokens,
|
|
57
|
+
apiOutputTokens: this._apiOutputTokens,
|
|
58
|
+
estimatedTokens: this._estimatedTokens,
|
|
59
|
+
tools,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reset all counters for a new session. */
|
|
64
|
+
reset(): void {
|
|
65
|
+
this._apiInputTokens = 0
|
|
66
|
+
this._apiOutputTokens = 0
|
|
67
|
+
this._estimatedTokens = 0
|
|
68
|
+
this._toolUsage.clear()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Total API tokens (input + output). */
|
|
72
|
+
get totalApiTokens(): number {
|
|
73
|
+
return this._apiInputTokens + this._apiOutputTokens
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether any real API usage data has been recorded. */
|
|
77
|
+
get hasApiData(): boolean {
|
|
78
|
+
return this._apiInputTokens > 0 || this._apiOutputTokens > 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Private helpers ──
|
|
82
|
+
|
|
83
|
+
private _recordTool(name: string, inputTokens: number, outputTokens: number): void {
|
|
84
|
+
const existing = this._toolUsage.get(name)
|
|
85
|
+
if (existing) {
|
|
86
|
+
existing.inputTokens += inputTokens
|
|
87
|
+
existing.outputTokens += outputTokens
|
|
88
|
+
existing.calls++
|
|
89
|
+
} else {
|
|
90
|
+
this._toolUsage.set(name, { inputTokens, outputTokens, calls: 1 })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private _recordToolEstimated(name: string, tokenCount: number): void {
|
|
95
|
+
const existing = this._toolUsage.get(name)
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.inputTokens += tokenCount
|
|
98
|
+
existing.calls++
|
|
99
|
+
} else {
|
|
100
|
+
this._toolUsage.set(name, { inputTokens: tokenCount, outputTokens: 0, calls: 1 })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|