@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/src/index.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { render } from 'ink'
3
4
  import { App } from './ui/app'
4
5
  import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
@@ -85,12 +86,52 @@ export async function runApp(options: RunOptions): Promise<void> {
85
86
  // Initialize plugin manager
86
87
  const pluginManager = new PluginManager()
87
88
 
89
+ // Generate session name for tracking (used by /cd to persist cwd)
90
+ const sessionName = options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
91
+
88
92
  // Initialize context — restore saved session if available
89
- const context = new ContextManager({ maxTokens: 200_000, compactionThreshold: 0.9 })
93
+ // Read the active model's context window for dynamic max-token sizing.
94
+ // MIPHAM_DISABLE_1M_CONTEXT=1 caps the effective window at 200K even for
95
+ // models that support larger contexts (e.g. 1M).
96
+ // When the model is unknown (not in any provider's model list), assume a
97
+ // conservative 128K context window. Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1
98
+ // to restore the old behavior (default 200K fallback for unknown models).
99
+ const activeModel = registry.findModel(defaultModel)
100
+ let modelContextWindow: number
101
+ if (activeModel) {
102
+ modelContextWindow = activeModel.contextWindow
103
+ } else if (process.env.MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT === '1') {
104
+ modelContextWindow = 200_000 // old behavior: default fallback
105
+ } else {
106
+ modelContextWindow = 128_000 // conservative assumption for unknown models
107
+ console.error(
108
+ `[mipham] ⚠ Unknown model "${defaultModel}": assuming 128K context window. ` +
109
+ `Set MIPHAM_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 to disable.`,
110
+ )
111
+ }
112
+
113
+ const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
114
+ const contextMaxTokens = (DISABLE_1M && modelContextWindow > 200_000)
115
+ ? 200_000
116
+ : modelContextWindow
117
+
118
+ if (DISABLE_1M && modelContextWindow <= 200_000) {
119
+ console.error('[mipham] ⚠ MIPHAM_DISABLE_1M_CONTEXT is set but auto-compaction is not holding the session to 200K — model context window is already ≤ 200K')
120
+ }
121
+
122
+ const context = new ContextManager({ maxTokens: contextMaxTokens, compactionThreshold: 0.9 })
90
123
 
91
124
  if (options.resume) {
92
125
  const saved = SessionStore.load(options.resume)
93
126
  if (saved) {
127
+ // Restore working directory if saved
128
+ if (saved.metadata.cwd && existsSync(saved.metadata.cwd)) {
129
+ try {
130
+ process.chdir(saved.metadata.cwd)
131
+ } catch {
132
+ // cwd may no longer exist; silently continue
133
+ }
134
+ }
94
135
  for (const msg of saved.messages) {
95
136
  context.addMessage(msg)
96
137
  }
@@ -169,6 +210,11 @@ export async function runApp(options: RunOptions): Promise<void> {
169
210
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
170
211
  }
171
212
 
213
+ // Apply org-level permission restrictions (P0: bypassPermissions policy gap)
214
+ if (config.permissionRestrictions) {
215
+ engine.getPermission().setRestrictions(config.permissionRestrictions)
216
+ }
217
+
172
218
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
173
219
  const agentRegistry = new AgentRegistry()
174
220
  agentRegistry.loadUserAgents()
@@ -190,9 +236,10 @@ export async function runApp(options: RunOptions): Promise<void> {
190
236
  const saveAndExit = () => {
191
237
  artifactServer.stop()
192
238
  if (context.getMessageCount() > 0) {
193
- SessionStore.autoSave(context.getMessages(), {
239
+ SessionStore.save(sessionName, context.getMessages(), {
194
240
  provider: defaultProvider,
195
241
  model: defaultModel,
242
+ cwd: process.cwd(),
196
243
  })
197
244
  }
198
245
  process.exit(0)
@@ -211,6 +258,7 @@ export async function runApp(options: RunOptions): Promise<void> {
211
258
  skillsLoader={skillsLoader}
212
259
  pluginManager={pluginManager}
213
260
  version={options.version}
261
+ sessionId={sessionName}
214
262
  />,
215
263
  )
216
264
  await waitUntilExit()
@@ -194,7 +194,15 @@ export class AnthropicProvider implements ProviderInstance {
194
194
  }
195
195
 
196
196
  case 'message_delta': {
197
- // Contains stop_reason and usage info
197
+ // Capture token usage for accurate cost tracking
198
+ if (event.usage) {
199
+ yield {
200
+ type: 'usage',
201
+ inputTokens: event.usage.input_tokens,
202
+ outputTokens: event.usage.output_tokens,
203
+ }
204
+ }
205
+ // Contains stop_reason; also handles late input_json_delta
198
206
  if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
199
207
  accumulatedToolInput += event.delta.partial_json
200
208
  }
@@ -99,6 +99,16 @@ export class OpenAICompatProvider implements ProviderInstance {
99
99
  try {
100
100
  const parsed = JSON.parse(data)
101
101
  const choice = parsed.choices?.[0]
102
+
103
+ // Capture token usage when available (final chunk with stream_options.include_usage)
104
+ if (parsed.usage) {
105
+ yield {
106
+ type: 'usage',
107
+ inputTokens: parsed.usage.prompt_tokens,
108
+ outputTokens: parsed.usage.completion_tokens,
109
+ }
110
+ }
111
+
102
112
  if (!choice) continue
103
113
 
104
114
  const delta = choice.delta
@@ -64,6 +64,14 @@ export class ProviderRegistry {
64
64
  return provider.config.models.filter((m) => m.status === 'active')
65
65
  }
66
66
 
67
+ findModel(modelId: string): ModelInfo | undefined {
68
+ for (const provider of this.providers.values()) {
69
+ const model = provider.config.models.find((m) => m.id === modelId)
70
+ if (model) return model
71
+ }
72
+ return undefined
73
+ }
74
+
67
75
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
68
76
  const provider = this.getActive()
69
77
  yield* provider.chat({ ...req, model: req.model || this.activeModelId })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Unicode sanitization for tool inputs.
3
+ * Strips invisible/control characters that could hide command content
4
+ * from visual inspection or enable homoglyph attacks.
5
+ *
6
+ * Claude Code v2.1.223 parity: hidden command text via tabs/invisible Unicode.
7
+ */
8
+
9
+ const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
10
+
11
+ /**
12
+ * Strip dangerous invisible Unicode characters from a string.
13
+ * - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
14
+ * - Bidi controls: U+202A-E, U+2066-9
15
+ * - Word joiner: U+2060
16
+ * - BOM: U+FEFF
17
+ */
18
+ export function stripDangerousUnicode(input: string): string {
19
+ if (!input) return input
20
+ return input.replace(DANGEROUS_UNICODE, '')
21
+ }
22
+
23
+ /**
24
+ * Recursively sanitize all string values in a params object.
25
+ */
26
+ export function sanitizeParams(params: Record<string, unknown>): Record<string, unknown> {
27
+ const result: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(params)) {
29
+ if (typeof value === 'string') {
30
+ result[key] = stripDangerousUnicode(value)
31
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
32
+ result[key] = sanitizeParams(value as Record<string, unknown>)
33
+ } else {
34
+ result[key] = value
35
+ }
36
+ }
37
+ return result
38
+ }
@@ -81,6 +81,7 @@ export interface ToolContext {
81
81
  artifactServer?: import('../artifacts/server').ArtifactServer
82
82
  agentRegistry?: import('../agent/agent-registry').AgentRegistry
83
83
  backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
84
+ permissionSystem?: import('../core/permission').PermissionSystem
84
85
  }
85
86
 
86
87
  // ── Artifact Types ──
@@ -128,7 +129,15 @@ export interface TaskNotification {
128
129
 
129
130
  // ── Stream Types ──
130
131
  export interface StreamChunk {
131
- type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error' | 'task_notification'
132
+ type:
133
+ | 'text'
134
+ | 'tool_use'
135
+ | 'tool_result'
136
+ | 'thinking'
137
+ | 'stop'
138
+ | 'error'
139
+ | 'task_notification'
140
+ | 'usage'
132
141
  content?: string
133
142
  toolUse?: ToolUseContent
134
143
  tool_use_id?: string
@@ -139,6 +148,10 @@ export interface StreamChunk {
139
148
  thinking?: string
140
149
  /** Background task notification payload (type: 'task_notification'). */
141
150
  taskNotification?: TaskNotification
151
+ /** API-reported input token count (type: 'usage'). */
152
+ inputTokens?: number
153
+ /** API-reported output token count (type: 'usage'). */
154
+ outputTokens?: number
142
155
  }
143
156
 
144
157
  // ── Config Types ──
@@ -147,8 +160,16 @@ export interface MiphamConfig {
147
160
  defaultProvider: string
148
161
  defaultModel: string
149
162
  permission: ToolPermission
163
+ /** Org-level permission restrictions (forbiddenModes, maxAllowedMode). */
164
+ permissionRestrictions?: PermissionRestrictions
150
165
  providers: ProviderConfig[]
151
166
  skills?: { paths: string[]; mcpServers: McpServerConfig[] }
167
+ marketplace?: {
168
+ /** If set, only allow installs from matching repos (e.g. ["One-Mipham/*"]) */
169
+ strictKnownMarketplaces?: string[]
170
+ /** Block installs from matching repos (e.g. ["malicious-org/*"]) */
171
+ blockedMarketplaces?: string[]
172
+ }
152
173
  }
153
174
 
154
175
  export interface McpServerConfig {
@@ -274,10 +295,20 @@ export type PermissionMode =
274
295
  /** Backward-compatible alias: PermissionMode plus legacy 'ask' and 'bypass' */
275
296
  export type PermissionLevel = PermissionMode | 'ask' | 'bypass'
276
297
 
298
+ /** Org-level restrictions that cap or forbid specific permission modes. */
299
+ export interface PermissionRestrictions {
300
+ /** Modes that may not be entered (cycle skips them). */
301
+ forbiddenModes?: PermissionMode[]
302
+ /** Ceiling — modes ranked higher (more permissive) than this are treated as forbidden. */
303
+ maxAllowedMode?: PermissionMode
304
+ }
305
+
277
306
  export interface PermissionConfig {
278
307
  mode: PermissionMode
279
308
  allow: string[]
280
309
  deny: string[]
310
+ /** Optional org-level restrictions enforced on every mode transition. */
311
+ restrictions?: PermissionRestrictions
281
312
  }
282
313
 
283
314
  export interface PermissionRuleEntry {
@@ -2,6 +2,7 @@ import { SubAgent } from '../agent/sub-agent'
2
2
  import type { ProviderRegistry } from '../providers/registry'
3
3
  import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
4
4
  import type { AgentDefinition } from '../agent/types'
5
+ import type { PermissionSystem } from '../core/permission'
5
6
 
6
7
  /**
7
8
  * Execute a skill in an isolated subagent context (context: fork).
@@ -15,6 +16,7 @@ export async function executeForkedSkill(
15
16
  args: string,
16
17
  registry: ProviderRegistry,
17
18
  toolRegistry: Map<string, ToolDefinition>,
19
+ permissionSystem?: PermissionSystem,
18
20
  ): Promise<string> {
19
21
  const agentDef: AgentDefinition = {
20
22
  name: `skill:${skill.name}`,
@@ -27,7 +29,7 @@ export async function executeForkedSkill(
27
29
  source: 'builtin',
28
30
  }
29
31
 
30
- const sub = new SubAgent(registry, toolRegistry)
32
+ const sub = new SubAgent(registry, toolRegistry, permissionSystem)
31
33
  const prompt = args
32
34
  ? `Execute the "${skill.name}" skill with arguments: ${args}`
33
35
  : `Execute the "${skill.name}" skill.`
@@ -10,6 +10,7 @@ import { join } from 'node:path'
10
10
  import { homedir } from 'node:os'
11
11
  import { spawnSync } from 'node:child_process'
12
12
  import { URL } from 'node:url'
13
+ import type { MiphamConfig } from '../shared/types.js'
13
14
 
14
15
  const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
15
16
 
@@ -141,13 +142,58 @@ export function searchSkills(query: string): SkillEntry[] {
141
142
  )
142
143
  }
143
144
 
145
+ /**
146
+ * Match a marketplace pattern against an owner/repo.
147
+ *
148
+ * - "owner/*" → matches any repo under that owner
149
+ * - "owner/repo" → exact match
150
+ */
151
+ function matchOwnerPattern(pattern: string, owner: string, repo: string): boolean {
152
+ if (pattern.endsWith('/*')) {
153
+ return pattern.slice(0, -2) === owner
154
+ }
155
+ return pattern === repo
156
+ }
157
+
158
+ /**
159
+ * Check whether a GitHub URL (or any URL) is allowed by the marketplace config.
160
+ *
161
+ * Rules (in order):
162
+ * 1. No config → allow everything
163
+ * 2. blockedMarketplaces matched → deny (deny wins over allow)
164
+ * 3. strictKnownMarketplaces set → deny unless matched
165
+ * 4. Non-GitHub URL with no strict list → allow
166
+ * 5. Non-GitHub URL with strict list → deny
167
+ */
168
+ function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace']): boolean {
169
+ if (!config) return true
170
+ const { strictKnownMarketplaces, blockedMarketplaces } = config
171
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)/)
172
+ if (!match) return !strictKnownMarketplaces?.length // non-GitHub: allow only if no strict list
173
+ const repo = match[1]! // "owner/repo"
174
+ const [owner] = repo.split('/')
175
+
176
+ // Check blocked first (deny wins)
177
+ if (blockedMarketplaces?.some((pattern) => matchOwnerPattern(pattern, owner!, repo))) return false
178
+ // Check strict allowlist
179
+ if (
180
+ strictKnownMarketplaces?.length &&
181
+ !strictKnownMarketplaces.some((pattern) => matchOwnerPattern(pattern, owner!, repo))
182
+ )
183
+ return false
184
+ return true
185
+ }
186
+
144
187
  /**
145
188
  * Install a skill from a GitHub repository.
146
189
  *
147
190
  * Clones the repo to a temp directory, copies the skill file(s),
148
191
  * and cleans up.
149
192
  */
150
- export function installSkill(skillName: string): InstallResult {
193
+ export function installSkill(
194
+ skillName: string,
195
+ marketplaceConfig?: MiphamConfig['marketplace'],
196
+ ): InstallResult {
151
197
  const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
152
198
  if (!entry) {
153
199
  return {
@@ -157,6 +203,15 @@ export function installSkill(skillName: string): InstallResult {
157
203
  }
158
204
  }
159
205
 
206
+ // Check marketplace restrictions
207
+ if (!isMarketplaceAllowed(entry.url, marketplaceConfig)) {
208
+ return {
209
+ success: false,
210
+ name: skillName,
211
+ message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${entry.url}`,
212
+ }
213
+ }
214
+
160
215
  // Built-in skills are already available — no download needed
161
216
  if (entry.builtin) {
162
217
  return {
@@ -214,7 +269,19 @@ export function installSkill(skillName: string): InstallResult {
214
269
  /**
215
270
  * Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
216
271
  */
217
- export function installSkillFromUrl(url: string): InstallResult {
272
+ export function installSkillFromUrl(
273
+ url: string,
274
+ marketplaceConfig?: MiphamConfig['marketplace'],
275
+ ): InstallResult {
276
+ // Check marketplace restrictions
277
+ if (!isMarketplaceAllowed(url, marketplaceConfig)) {
278
+ return {
279
+ success: false,
280
+ name: url,
281
+ message: `URL is from a blocked or unapproved marketplace: ${url}`,
282
+ }
283
+ }
284
+
218
285
  // Derive skill name from URL
219
286
  const name =
220
287
  url
@@ -60,7 +60,7 @@ export const agentTool: ToolDefinition = {
60
60
  const agentDef = ctx.agentRegistry?.resolve(agentType)
61
61
 
62
62
  try {
63
- const sub = new SubAgent(registry, toolRegistry)
63
+ const sub = new SubAgent(registry, toolRegistry, ctx.permissionSystem)
64
64
  const result = await sub.execute(prompt, description, {
65
65
  type: agentType,
66
66
  agentDef,
@@ -52,6 +52,7 @@ export const skillTool: ToolDefinition = {
52
52
  args,
53
53
  registry,
54
54
  ctx.toolRegistry || new Map(),
55
+ ctx.permissionSystem,
55
56
  )
56
57
  // Return to AI as internal context
57
58
  return { success: true, content: `[Forked skill "${skillName}" result]:\n${result}` }
@@ -52,6 +52,18 @@ const BLOCKED_PATTERNS = [
52
52
  /\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
53
53
  // Write to system paths
54
54
  />\s*\/(?:etc|usr|boot|sys|proc)\//,
55
+ // P0 hardening — ANSI-C quoting bypass (e.g. $'\x72\x6d' = rm)
56
+ /\$'\\x[0-9a-fA-F]{2}/,
57
+ // P0 hardening — nested interpreter invocation
58
+ /\b(?:bash|sh|zsh|dash|ksh)\s+-c\b/,
59
+ // P0 hardening — eval builtin (obfuscation vector)
60
+ /\beval\s+/,
61
+ // P0 hardening — exec redirect bypass (e.g. exec >/dev/sda)
62
+ /\bexec\s+\d*>/,
63
+ // P0 hardening — source/dot builtin (script sourcing)
64
+ /\bsource\s+/,
65
+ // P0 hardening — base64 decode + pipe
66
+ /\bbase64\s+(?:-d|--decode)\b/,
55
67
  ]
56
68
 
57
69
  const BLOCKED_COMMANDS = [
@@ -71,18 +83,38 @@ const BLOCKED_COMMANDS = [
71
83
  'init',
72
84
  'telinit',
73
85
  'systemctl',
86
+ 'eval',
87
+ 'exec',
88
+ 'source',
89
+ '.',
74
90
  ]
75
91
 
92
+ /**
93
+ * Normalize ANSI-C escape sequences ($'...') in a command string.
94
+ * Converts hex escapes (\xHH) back to literal characters so that
95
+ * existing patterns (e.g. rm -rf /) still catch obfuscated payloads.
96
+ */
97
+ function normalizeEscapes(command: string): string {
98
+ return command.replace(/\$'([^']*)'/g, (_, inner: string) =>
99
+ inner.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
100
+ String.fromCharCode(parseInt(hex, 16)),
101
+ ),
102
+ )
103
+ }
104
+
76
105
  function isBlocked(command: string): string | null {
77
- // Check exact blocked commands
78
- const firstWord = command.trim().split(/\s+/)[0]
106
+ // Normalize ANSI-C escape sequences for defense-in-depth
107
+ const normalized = normalizeEscapes(command)
108
+
109
+ // Check exact blocked commands (on normalized command)
110
+ const firstWord = normalized.trim().split(/\s+/)[0]
79
111
  if (firstWord && BLOCKED_COMMANDS.includes(firstWord)) {
80
- return `Command "${firstWord}" is blocked (destructive filesystem operation).`
112
+ return `Command "${firstWord}" rejected by security policy.`
81
113
  }
82
114
 
83
- // Check dangerous patterns
115
+ // Check dangerous patterns (on both original and normalized)
84
116
  for (const pattern of BLOCKED_PATTERNS) {
85
- if (pattern.test(command)) {
117
+ if (pattern.test(command) || pattern.test(normalized)) {
86
118
  return `Command rejected by security policy. Pattern matched: ${pattern.source.slice(0, 40)}...`
87
119
  }
88
120
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition, ToolResult } from '../shared/index.ts'
2
+ import { sanitizeParams } from '../shared/sanitize'
2
3
  import { readTool } from './file/read'
3
4
  import { writeTool } from './file/write'
4
5
  import { editTool } from './file/edit'
@@ -96,11 +97,13 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
96
97
  return {
97
98
  ...tool,
98
99
  async execute(params, ctx): Promise<ToolResult> {
99
- const errors = validateParams(schema, params)
100
+ // Sanitize dangerous Unicode from all inputs
101
+ const cleanParams = sanitizeParams(params)
102
+ const errors = validateParams(schema, cleanParams)
100
103
  if (errors.length > 0) {
101
104
  return { success: false, content: '', error: `Invalid parameters: ${errors.join('; ')}` }
102
105
  }
103
- return tool.execute(params, ctx)
106
+ return tool.execute(cleanParams, ctx)
104
107
  },
105
108
  }
106
109
  }
package/src/ui/app.tsx CHANGED
@@ -1,9 +1,10 @@
1
- import React, { useState, useCallback, useRef, useMemo } from 'react'
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import type { QueryEngine } from '../core/engine'
4
4
  import type { MiphamConfig } from '../shared/index.ts'
5
5
  import type { SkillsLoader } from '../skills/loader'
6
6
  import type { PluginManager } from '../plugin/plugin-manager'
7
+ import { getPreference, setPreference } from '../config/preferences'
7
8
  import { AgentRegistry } from '../agent/agent-registry'
8
9
  import { ChatPanel } from './chat'
9
10
  import { InputBar } from './input'
@@ -26,6 +27,7 @@ interface AppProps {
26
27
  skillsLoader?: SkillsLoader
27
28
  pluginManager?: PluginManager
28
29
  version?: string
30
+ sessionId?: string
29
31
  }
30
32
 
31
33
  export interface ToolMeta {
@@ -84,6 +86,7 @@ export function App({
84
86
  skillsLoader,
85
87
  pluginManager,
86
88
  version,
89
+ sessionId,
87
90
  }: AppProps) {
88
91
  const [messages, setMessages] = useState<ChatMessage[]>([])
89
92
  const [isLoading, setIsLoading] = useState(false)
@@ -100,6 +103,12 @@ export function App({
100
103
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
101
104
  const [agentElapsed, setAgentElapsed] = useState(0)
102
105
 
106
+ // Load persisted effort on mount
107
+ useEffect(() => {
108
+ const saved = getPreference('lastCodeReviewEffort', 'high')
109
+ setEffort(saved)
110
+ }, [])
111
+
103
112
  // Agent elapsed timer
104
113
  React.useEffect(() => {
105
114
  if (!agentProgress) {
@@ -131,15 +140,19 @@ export function App({
131
140
  providerId,
132
141
  modelId,
133
142
  version: version || '0.0.0',
143
+ sessionId: sessionId || '',
134
144
  setSessionTitle: (title: string) => setSessionTitle(title),
135
145
  setFastMode: (on: boolean) => setFastMode(on),
136
- setEffort: (level: string) => setEffort(level),
146
+ setEffort: (level: string) => {
147
+ setEffort(level)
148
+ setPreference('lastCodeReviewEffort', level)
149
+ },
137
150
  setFocusMode: (on: boolean) => setFocusMode(on),
138
151
  setGoal: (text: string) => setGoalText(text),
139
152
  skillsLoader,
140
153
  pluginManager,
141
154
  }),
142
- [engine, config, providerId, modelId, skillsLoader, pluginManager],
155
+ [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
143
156
  )
144
157
 
145
158
  const handleSubmit = useCallback(