@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
  */
@@ -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: resolvedModel,
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: process.cwd(),
268
+ cwd: execCwd,
235
269
  sessionId: 'sub-agent',
236
270
  provider: '',
237
- model: resolvedModel,
271
+ model: finalModel,
238
272
  })
239
273
 
240
274
  currentMessages.push({
@@ -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
  }
@@ -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
+ }
@@ -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
@@ -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('bypass'),
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
- /** Valid mode transition order for Shift+Tab cycling. */
22
- export const MODE_CYCLE: PermissionMode[] = [
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
- export function nextMode(current: PermissionMode): PermissionMode {
32
- const idx = MODE_CYCLE.indexOf(current)
33
- return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length]!
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
  }
@@ -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: { mode?: string; allow?: string[]; deny?: string[] }): void {
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<{ mode: PermissionMode; allow: string[]; deny: string[] }>,
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
- if (level === 'auto') this.mode = 'auto'
245
- else if (level === 'bypass') this.mode = 'bypassPermissions'
246
- else this.mode = 'default'
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
+ }