@bibobonking/opensol 3.1.3 → 3.1.5

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +48 -19
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -21
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +45 -0
  59. package/src/lib/retry.ts +46 -0
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-DaJcH9ts.css +0 -1
  69. package/dist/assets/index-hs0KWMP6.js +0 -288
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
@@ -52,6 +52,8 @@ function approvalCopy(name: ToolName) {
52
52
  return 'OpenSol wants to change the Git state of this repository.'
53
53
  case 'subagent':
54
54
  return 'OpenSol wants to start a read-only subagent to investigate.'
55
+ case 'skill':
56
+ return 'OpenSol wants to load an installed skill.'
55
57
  case 'web_search':
56
58
  return 'OpenSol wants to search the web.'
57
59
  case 'read_url':
@@ -2,7 +2,6 @@ import {
2
2
  FolderOpen,
3
3
  ListChecks,
4
4
  Menu,
5
- MessageSquareCode,
6
5
  Moon,
7
6
  PanelLeftOpen,
8
7
  Rocket,
@@ -20,8 +19,7 @@ import { cn } from '../lib/utils'
20
19
  import { themeIsDark } from '../lib/themes'
21
20
  import { LogoMark } from './Logo'
22
21
  import { ContextBar } from './ContextBar'
23
- import { SystemPromptModal } from './SystemPromptModal'
24
- import { useState } from 'react'
22
+ import { SystemPromptButton } from './SystemPromptButton'
25
23
 
26
24
  export type ChatViewMode = 'chat' | 'trajectory'
27
25
 
@@ -47,11 +45,7 @@ export function TopBar({
47
45
  const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
48
46
  const setDeployOpen = useUi((state) => state.setDeployOpen)
49
47
  const workspace = useWorkspace((state) => state.info)
50
- const workspaceRoot = useWorkspace((state) => state.root)
51
48
  const activeModel = useActiveModel()
52
- const [promptOpen, setPromptOpen] = useState(false)
53
- const root = session?.workspace || workspaceRoot || ''
54
- const promptIsCustom = Boolean(session?.systemPrompt?.trim() || (root && settings.workspacePrompts[root]?.trim()))
55
49
 
56
50
  // Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
57
51
  const isDark = themeIsDark(
@@ -133,17 +127,6 @@ export function TopBar({
133
127
  <ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
134
128
  </div>
135
129
  )}
136
- <button
137
- type="button"
138
- className={cn('icon-button relative hidden sm:inline-flex', promptIsCustom && 'text-accent')}
139
- onClick={() => setPromptOpen(true)}
140
- disabled={!session}
141
- title={promptIsCustom ? 'Custom system prompt for this session or workspace' : 'Edit the system prompt for this session'}
142
- aria-label="Edit system prompt"
143
- >
144
- <MessageSquareCode size={16} />
145
- {promptIsCustom && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-accent" />}
146
- </button>
147
130
  <button
148
131
  type="button"
149
132
  onClick={() => setWorkspaceOpen(!workspaceOpen)}
@@ -158,6 +141,7 @@ export function TopBar({
158
141
  <FolderOpen size={13} className="shrink-0" />
159
142
  <span className="max-w-[140px] truncate">{workspace.available ? workspace.name : 'No folder'}</span>
160
143
  </button>
144
+ <SystemPromptButton session={session} />
161
145
  <button
162
146
  type="button"
163
147
  className="icon-button hidden sm:inline-flex"
@@ -195,7 +179,6 @@ export function TopBar({
195
179
  <Settings size={16} />
196
180
  </button>
197
181
  </div>
198
- <SystemPromptModal session={session} open={promptOpen} onClose={() => setPromptOpen(false)} />
199
182
  </header>
200
183
  )
201
184
  }
@@ -26,9 +26,9 @@ import { cn, formatBytes } from '../lib/utils'
26
26
  import { useUi } from '../store/ui'
27
27
  import { useWorkspace } from '../store/workspace'
28
28
  import { PreviewPane } from './PreviewPane'
29
+ import { FileIcon, FolderIcon } from './FileIcon'
29
30
  import { SessionsTab } from './SessionsTab'
30
31
  import { useChats } from '../store/chats'
31
- import { FileIcon } from './FileIcon'
32
32
  import { Spinner } from './ui'
33
33
 
34
34
  interface TreeNode {
@@ -269,6 +269,20 @@ export function WorkspacePanel() {
269
269
  return () => window.removeEventListener('keydown', onKey)
270
270
  }, [])
271
271
 
272
+ const gitStatus = useMemo(() => {
273
+ const files = new Map<string, string>()
274
+ const dirs = new Set<string>()
275
+ for (const change of git.entries) {
276
+ files.set(change.path, change.status.trim() || 'M')
277
+ let slash = change.path.lastIndexOf('/')
278
+ while (slash > 0) {
279
+ dirs.add(change.path.slice(0, slash))
280
+ slash = change.path.lastIndexOf('/', slash - 1)
281
+ }
282
+ }
283
+ return { files, dirs }
284
+ }, [git.entries])
285
+
272
286
  if (!open) return null
273
287
 
274
288
  const submitEditor = async () => {
@@ -313,10 +327,15 @@ export function WorkspacePanel() {
313
327
  }
314
328
  }
315
329
 
330
+ const statusTone = (status: string) =>
331
+ status.includes('?') || status.includes('A') ? 'text-success' : status.includes('D') ? 'text-danger' : 'text-warning'
332
+
316
333
  const renderNode = (node: TreeNode, depth: number): ReactNode => {
317
334
  const path = node.entry.path
335
+ const indent = 10 + depth * 14
318
336
  if (node.entry.type === 'dir') {
319
337
  const closed = collapsed.has(path)
338
+ const touched = gitStatus.dirs.has(path)
320
339
  return (
321
340
  <div key={path}>
322
341
  <button
@@ -329,13 +348,18 @@ export function WorkspacePanel() {
329
348
  return next
330
349
  })
331
350
  }
332
- className="group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[11.5px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
333
- style={{ paddingLeft: 8 + depth * 13 }}
351
+ className="group flex h-[26px] w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[12px] font-medium text-ink/85 transition-colors hover:bg-raised hover:text-ink"
352
+ style={{ paddingLeft: indent }}
334
353
  title={path}
354
+ aria-expanded={!closed}
335
355
  >
336
- <ChevronRight size={10} className={cn('shrink-0 text-faint transition-transform', !closed && 'rotate-90')} />
337
- <FileIcon path={path} type="dir" open={!closed} size={13} />
356
+ <ChevronRight size={11} strokeWidth={2.2} className={cn('shrink-0 text-faint transition-transform duration-150', !closed && 'rotate-90')} />
357
+ <FolderIcon name={node.entry.name} open={!closed} size={16} />
338
358
  <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
359
+ {touched && closed && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" title="Contains changes" />}
360
+ {closed && node.children.length > 0 && (
361
+ <span className="shrink-0 rounded-full bg-raised px-1.5 text-[9px] tabular-nums text-faint group-hover:hidden">{node.children.length}</span>
362
+ )}
339
363
  <span
340
364
  role="button"
341
365
  tabIndex={0}
@@ -349,32 +373,36 @@ export function WorkspacePanel() {
349
373
  className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
350
374
  aria-label={`Delete ${path}`}
351
375
  >
352
- <Trash2 size={10} />
376
+ <Trash2 size={11} />
353
377
  </span>
354
378
  </button>
355
- {!closed && node.children.map((child) => renderNode(child, depth + 1))}
379
+ {!closed && node.children.length > 0 && (
380
+ <div className="relative">
381
+ <span aria-hidden className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/70" style={{ left: indent + 5 }} />
382
+ {node.children.map((child) => renderNode(child, depth + 1))}
383
+ </div>
384
+ )}
356
385
  </div>
357
386
  )
358
387
  }
388
+ const active = activeFile === path
389
+ const status = gitStatus.files.get(path)
359
390
  return (
360
391
  <button
361
392
  key={path}
362
393
  type="button"
363
394
  onClick={() => setActiveFile(path)}
364
395
  className={cn(
365
- 'group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
366
- activeFile === path ? 'bg-accent-soft font-medium text-accent' : 'text-muted hover:bg-raised hover:text-ink',
396
+ 'group relative flex h-[26px] w-full items-center gap-1.5 rounded-md pr-2 text-left text-[11.5px] transition-colors',
397
+ active ? 'bg-accent-soft font-medium text-ink' : 'text-muted hover:bg-raised hover:text-ink',
367
398
  )}
368
- style={{ paddingLeft: 8 + depth * 13 + 10 }}
399
+ style={{ paddingLeft: indent + 13 }}
369
400
  title={path}
370
401
  >
371
- <FileIcon path={path} size={12} className={cn(activeFile === path && 'text-accent')} />
372
- <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
373
- {node.entry.size > 0 && (
374
- <span className="shrink-0 text-[8.5px] tabular-nums text-faint/70 opacity-0 transition-opacity group-hover:opacity-100">
375
- {formatBytes(node.entry.size)}
376
- </span>
377
- )}
402
+ {active && <span aria-hidden className="absolute inset-y-1 left-0 w-[3px] rounded-r-full bg-accent" />}
403
+ <FileIcon name={node.entry.name} size={15} />
404
+ <span className={cn('min-w-0 flex-1 truncate', status && statusTone(status))}>{node.entry.name}</span>
405
+ {status && <span className={cn('shrink-0 font-mono text-[9px] font-semibold', statusTone(status))}>{status.slice(0, 1)}</span>}
378
406
  </button>
379
407
  )
380
408
  }
@@ -564,7 +592,7 @@ export function WorkspacePanel() {
564
592
  : 'text-muted hover:bg-raised hover:text-ink',
565
593
  )}
566
594
  >
567
- {entry.type === 'dir' ? <FileIcon path={entry.path} type="dir" size={12} /> : <FileIcon path={entry.path} size={12} />}
595
+ {entry.type === 'dir' ? <FolderIcon name={entry.name} size={15} /> : <FileIcon name={entry.name} size={15} />}
568
596
  <span className="truncate">{entry.path}</span>
569
597
  </button>
570
598
  ))
@@ -688,7 +716,8 @@ export function WorkspacePanel() {
688
716
  className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
689
717
  title={entry.path}
690
718
  >
691
- <span className="w-6 shrink-0 text-warning">{entry.status}</span>
719
+ <span className="w-6 shrink-0 font-mono font-semibold text-warning">{entry.status}</span>
720
+ <FileIcon name={entry.path.split('/').pop() ?? entry.path} size={14} />
692
721
  <span className="truncate text-muted">{entry.path}</span>
693
722
  </button>
694
723
  ))
@@ -1,101 +1,44 @@
1
1
  import type { Context } from 'cordis'
2
- import { executeDaemonTool } from '../../../lib/daemon'
3
- import type { Skill } from '../../../types'
2
+ import { readSkillApi } from '../../../lib/daemon'
4
3
  import type { ToolResult } from '../../types'
5
4
 
6
- function findSkill(skills: Skill[], name: string): Skill | undefined {
7
- const clean = name.trim().toLowerCase()
8
- return skills.find((skill) => skill.id.toLowerCase() === clean || skill.name.toLowerCase() === clean)
9
- }
10
-
11
- function text(args: Record<string, unknown>, key: string): string {
12
- return typeof args[key] === 'string' ? String(args[key]).trim() : ''
13
- }
5
+ const MAX_TOOL_OUTPUT = 60_000
14
6
 
15
7
  export const name = 'tool-skill'
16
8
  export const inject = ['tools']
17
9
 
18
10
  export function apply(ctx: Context) {
19
- const unregister = ctx.tools.register({
11
+ return ctx.tools.register({
20
12
  name: 'skill',
21
13
  description:
22
- 'Work with installed skills. Use action "list" to see them, "read" to load a skill’s SKILL.md, and "run" to execute a command inside the skill folder.',
14
+ 'Load an installed skill by name. With only `name` it returns the skill\'s SKILL.md instructions and the list of bundled files. Pass `file` (relative path, e.g. "references/api.md") to read one bundled file. Skill scripts can be run with the shell tool from the returned directory.',
23
15
  parameters: {
24
16
  type: 'object',
25
17
  properties: {
26
- action: { type: 'string', enum: ['list', 'read', 'run'], description: 'What to do.' },
27
- name: { type: 'string', description: 'Skill id or name. Required for read and run.' },
28
- command: { type: 'string', description: 'Shell command to run inside the skill folder. Required for run.' },
18
+ name: { type: 'string', description: 'Skill name from the Skills list in the system prompt.' },
19
+ file: { type: 'string', description: 'Optional bundled file to read, relative to the skill folder.' },
29
20
  },
30
- required: ['action'],
21
+ required: ['name'],
31
22
  additionalProperties: false,
32
23
  },
33
24
  allowedInMode: () => true,
34
- async execute(args, exec): Promise<ToolResult> {
35
- const skills = exec.settings.skills.filter((skill) => skill.enabled)
36
- const action = text(args, 'action') || 'list'
37
-
38
- if (action === 'list') {
39
- if (skills.length === 0) return { output: 'No skills are installed.', isError: false }
40
- return {
41
- output: skills
42
- .map((skill) => `${skill.name} (${skill.id})${skill.package ? ` · ${skill.package}` : ''}\n ${skill.description}`)
43
- .join('\n'),
44
- isError: false,
45
- }
46
- }
47
-
48
- const target = findSkill(skills, text(args, 'name'))
49
- if (!target) {
50
- return {
51
- output: `No enabled skill matches "${text(args, 'name')}". Call action "list" to see the available skills.`,
52
- isError: true,
25
+ async execute(args): Promise<ToolResult> {
26
+ const skillName = typeof args.name === 'string' ? args.name.trim() : ''
27
+ if (!skillName) return { output: 'Missing string argument "name".', isError: true }
28
+ try {
29
+ const result = await readSkillApi(skillName, typeof args.file === 'string' ? args.file : undefined)
30
+ const parts = [`Skill: ${result.name} · ${result.file}`, `Directory: ${result.directory}`, '────', result.content]
31
+ if (result.files && result.files.length > 0) {
32
+ parts.push('────', `Bundled files (read with this tool's "file" argument):\n${result.files.map((item) => `- ${item}`).join('\n')}`)
53
33
  }
54
- }
55
-
56
- if (action === 'read') {
57
- const files = Object.keys(target.files ?? {})
34
+ const text = parts.join('\n')
58
35
  return {
59
- output: [
60
- `# ${target.name}`,
61
- target.description,
62
- target.package ? `Package: ${target.package}` : '',
63
- files.length > 0 ? `Files:\n${files.map((file) => `- ${file}`).join('\n')}` : '',
64
- '---',
65
- target.instructions.trim() || 'This skill has no instructions.',
66
- ]
67
- .filter(Boolean)
68
- .join('\n\n'),
36
+ output: text.length > MAX_TOOL_OUTPUT ? `${text.slice(0, MAX_TOOL_OUTPUT)}\n… truncated` : text,
69
37
  isError: false,
70
38
  }
39
+ } catch (error) {
40
+ return { output: error instanceof Error ? error.message : 'Could not load the skill.', isError: true }
71
41
  }
72
-
73
- if (action === 'run') {
74
- if (exec.mode === 'plan') {
75
- return { output: 'Plan mode is read-only: skill scripts may change the machine, so they cannot run here.', isError: true }
76
- }
77
- const command = text(args, 'command')
78
- if (!command) return { output: 'Pass the command to run inside the skill folder.', isError: true }
79
- if (!target.files || Object.keys(target.files).length === 0) {
80
- return { output: `"${target.name}" has no files to run. Install it from a folder or npm.`, isError: true }
81
- }
82
- const result = (await executeDaemonTool(
83
- 'skill_run',
84
- { name: target.id, files: target.files, command },
85
- exec.mode,
86
- exec.signal,
87
- )).result as { stdout?: string; stderr?: string; code?: number }
88
- const code = typeof result.code === 'number' ? result.code : 1
89
- const body = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
90
- return {
91
- output: body || `The skill command exited with code ${code} and printed nothing.`,
92
- isError: code !== 0,
93
- }
94
- }
95
-
96
- return { output: `Unknown skill action "${action}". Use list, read or run.`, isError: true }
97
42
  },
98
43
  })
99
-
100
- return () => unregister()
101
44
  }
@@ -4,18 +4,15 @@ import { activeModelId, useSettings } from '../../store/settings'
4
4
  import { createId } from '../../store/chats'
5
5
  import { findModel } from '../../store/models'
6
6
  import { useRuntime } from '../../store/ui'
7
- import { useWorkspace } from '../../store/workspace'
8
7
  import { getProviderName } from '../../data/providers'
9
8
  import { describeRequestError } from '../../lib/sse'
9
+ import { messageCost } from '../../lib/credits'
10
+ import { MAX_RETRIES, backoffMs, classifyProviderError } from '../../lib/retry'
10
11
  import type { AdapterResult } from '../../agent/adapters/shared'
11
12
  import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
12
13
  import { resolveToolSetting } from '../mcp-tools'
13
14
  import type { StepContext, StepResult, TurnResult } from '../types'
14
15
 
15
- const MAX_RETRIES = 2
16
- /** Provider hiccups worth another try: overload, rate limits, 5xx and dropped connections. */
17
- const RETRYABLE = /\b(429|500|502|503|504|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror/i
18
-
19
16
  function sleep(ms: number, signal: AbortSignal): Promise<void> {
20
17
  return new Promise((resolve) => {
21
18
  const finish = () => {
@@ -112,7 +109,7 @@ export class AgentLoopService extends Service {
112
109
  let turnError: string | undefined
113
110
 
114
111
  try {
115
- // No round cap: the turn ends when the model stops calling tools or the user stops it.
112
+ // No round cap: the turn ends when the model stops calling tools, errors, or the user stops it.
116
113
  rounds: for (let round = 0; ; round += 1) {
117
114
  totalRounds = round + 1
118
115
  if (controller.signal.aborted) break
@@ -189,12 +186,7 @@ export class AgentLoopService extends Service {
189
186
  settings,
190
187
  model: findModel(settings.provider, activeModelId(settings)),
191
188
  history,
192
- system: this.ctx.systemPrompt.assemble(settings, mode, {
193
- sessionPrompt: current.systemPrompt,
194
- workspacePrompt: current.workspace
195
- ? settings.workspacePrompts[current.workspace]
196
- : settings.workspacePrompts[useWorkspace.getState().root ?? ''],
197
- }),
189
+ system: this.ctx.systemPrompt.assemble(settings, mode, sessionId),
198
190
  mode,
199
191
  signal: controller.signal,
200
192
  handlers: {
@@ -216,24 +208,22 @@ export class AgentLoopService extends Service {
216
208
  } catch (error) {
217
209
  const stopped = isAbortError(error) || controller.signal.aborted
218
210
  // Only retry when nothing reached the screen yet, so a retry never duplicates text.
211
+ const message = error instanceof Error ? error.message : ''
212
+ const decision = classifyProviderError(message)
219
213
  const retryable =
220
- !stopped &&
221
- attempt < MAX_RETRIES &&
222
- !streamedContent &&
223
- !streamedReasoning &&
224
- RETRYABLE.test(error instanceof Error ? error.message : '')
214
+ !stopped && decision.retry && attempt < MAX_RETRIES && !streamedContent && !streamedReasoning
225
215
  if (retryable) {
226
216
  attempt += 1
217
+ const wait = backoffMs(attempt, message)
227
218
  window.dispatchEvent(
228
219
  new CustomEvent('maxcode:notice', {
229
220
  detail: {
230
- message: `${providerName} is busy. Retrying (${attempt}/${MAX_RETRIES})…`,
221
+ message: `${providerName} had a temporary error. Retrying in ${Math.round(wait / 1000)}s (${attempt}/${MAX_RETRIES})…`,
231
222
  tone: 'info',
232
223
  },
233
224
  }),
234
225
  )
235
- await sleep(1200 * attempt * attempt, controller.signal)
236
- if (controller.signal.aborted) continue
226
+ await sleep(wait, controller.signal)
237
227
  continue
238
228
  }
239
229
  if (flushTimer !== undefined) window.clearTimeout(flushTimer)
@@ -256,7 +246,11 @@ export class AgentLoopService extends Service {
256
246
  reasoning: result.reasoning,
257
247
  thinkingBlocks: result.thinkingBlocks,
258
248
  toolCalls: result.toolCalls,
259
- usage: usage.input || usage.output || usage.cost ? usage : result.usage,
249
+ usage: usage.input || usage.output ? usage : result.usage,
250
+ cost: messageCost(
251
+ usage.input || usage.output ? usage : result.usage,
252
+ findModel(settings.provider, activeModelId(settings)),
253
+ ),
260
254
  durationMs: Date.now() - startedAt,
261
255
  stopped: controller.signal.aborted || undefined,
262
256
  })
@@ -321,6 +315,7 @@ export class AgentLoopService extends Service {
321
315
  typeof call.args.task === 'string' ? call.args.task : '',
322
316
  typeof call.args.context === 'string' ? call.args.context : '',
323
317
  controller.signal,
318
+ sessionId,
324
319
  ),
325
320
  ),
326
321
  isError: false,
@@ -9,7 +9,7 @@ export class SubagentService extends Service {
9
9
  super(ctx, 'subagents')
10
10
  }
11
11
 
12
- async run(task: string, context: string, signal: AbortSignal): Promise<string> {
13
- return await runSubagent(task, context, signal)
12
+ async run(task: string, context: string, signal: AbortSignal, sessionId?: string): Promise<string> {
13
+ return await runSubagent(task, context, signal, sessionId)
14
14
  }
15
15
  }
@@ -2,6 +2,8 @@ import { Service, type Context } from 'cordis'
2
2
  import type { AgentMode, AppSettings } from '../../types'
3
3
  import { activeModelId } from '../../store/settings'
4
4
  import { useWorkspace } from '../../store/workspace'
5
+ import { skillCatalogPrompt } from '../../store/skills'
6
+ import { resolveBasePrompt } from '../../lib/system-prompt'
5
7
  import type { PromptSection } from '../types'
6
8
 
7
9
  function workspaceContext(): string {
@@ -43,31 +45,22 @@ export class SystemPromptService extends Service {
43
45
  return () => this.customSections.delete(name)
44
46
  }
45
47
 
46
- assemble(
47
- settings: AppSettings,
48
- mode: AgentMode = settings.mode,
49
- overrides: { sessionPrompt?: string; workspacePrompt?: string } = {},
50
- ): string {
48
+ assemble(settings: AppSettings, mode: AgentMode = settings.mode, sessionId?: string): string {
51
49
  const sections: PromptSection[] = []
52
50
 
53
- // 1. Base instruction: session override, then workspace override, then the shared prompt.
54
- const base = overrides.sessionPrompt?.trim() || overrides.workspacePrompt?.trim() || settings.systemPrompt?.trim()
55
- if (base) {
56
- sections.push({ name: 'base', priority: 10, content: base })
51
+ // 1. Base instruction
52
+ // Session prompt beats workspace prompt beats the app-wide one.
53
+ const workspaceState = useWorkspace.getState()
54
+ const session = sessionId ? this.ctx.sessions.current(sessionId) : undefined
55
+ const base = resolveBasePrompt(settings, session, workspaceState.root || workspaceState.info.root)
56
+ if (base.text.trim()) {
57
+ sections.push({ name: 'base', priority: 10, content: base.text.trim() })
57
58
  }
58
59
 
59
- // 2. Skills are discovered, not pasted: the model loads one through the skill tool.
60
- const skills = settings.tools.skill?.enabled === false ? [] : settings.skills.filter((skill) => skill.enabled)
61
- if (skills.length > 0) {
62
- sections.push({
63
- name: 'skills',
64
- priority: 20,
65
- content: [
66
- '## Available skills',
67
- 'Call the `skill` tool to load the full instructions (and run scripts from the skill folder) when a task matches one of these. Do not guess a skill’s contents.',
68
- ...skills.map((skill) => `- ${skill.name} (${skill.id}): ${skill.description}`),
69
- ].join('\n'),
70
- })
60
+ // 2. Skill catalog (names + descriptions; bodies load on demand through the skill tool)
61
+ const catalog = skillCatalogPrompt()
62
+ if (catalog && settings.tools.skill?.enabled !== false) {
63
+ sections.push({ name: 'skills', priority: 20, content: catalog })
71
64
  }
72
65
 
73
66
  // 3. Workspace
@@ -163,9 +156,9 @@ export class SystemPromptService extends Service {
163
156
  })
164
157
  }
165
158
 
166
- buildSubagentPrompt(settings: AppSettings): string {
159
+ buildSubagentPrompt(settings: AppSettings, sessionId?: string): string {
167
160
  return [
168
- this.assemble(settings, 'plan'),
161
+ this.assemble(settings, 'plan', sessionId),
169
162
  [
170
163
  '## Subagent',
171
164
  'You are a focused read-only investigator. Work only on the assigned task.',
@@ -15,7 +15,6 @@ const READ_ONLY_TOOLS = new Set<ToolName>([
15
15
  'grep',
16
16
  'web_search',
17
17
  'read_url',
18
- 'skill',
19
18
  ])
20
19
 
21
20
  export function toolAllowedInMode(name: ToolName, mode: AgentMode): boolean {
package/src/lib/boot.ts CHANGED
@@ -4,6 +4,7 @@ import { ensureSession, getState, migrateLegacy } from './daemon'
4
4
  import { enableChatPersistence, useChats } from '../store/chats'
5
5
  import { enableSettingsPersistence, useSettings } from '../store/settings'
6
6
  import { useWorkspace } from '../store/workspace'
7
+ import { useSkills } from '../store/skills'
7
8
 
8
9
  const LEGACY_KEYS = {
9
10
  settings: 'maxcode-settings-v1',
@@ -59,6 +60,7 @@ export async function applySnapshot(snapshot: DaemonSnapshot): Promise<void> {
59
60
  useSettings.getState().hydrate(snapshot)
60
61
  useChats.getState().hydrate(snapshot.chats)
61
62
  await useWorkspace.getState().hydrate(snapshot.workspace)
63
+ void useSkills.getState().load()
62
64
  }
63
65
 
64
66
  function parseLegacy(value: string | null): unknown {
@@ -0,0 +1,33 @@
1
+ import type { Message, ModelInfo, Usage } from '../types'
2
+
3
+ /**
4
+ * Credits for one reply. Providers that report their own cost win; otherwise the cost is
5
+ * tokens × the model's published price (USD per 1M tokens). Unknown or free models give
6
+ * undefined, so nothing is shown for them.
7
+ */
8
+ export function messageCost(
9
+ usage: Usage | undefined,
10
+ model: Pick<ModelInfo, 'pricing'> | null | undefined,
11
+ ): number | undefined {
12
+ if (!usage) return undefined
13
+ if (typeof usage.cost === 'number' && Number.isFinite(usage.cost) && usage.cost > 0) return usage.cost
14
+ const pricing = model?.pricing
15
+ if (!pricing) return undefined
16
+ const cost = ((usage.input ?? 0) * pricing.input + (usage.output ?? 0) * pricing.output) / 1_000_000
17
+ return cost > 0 ? cost : undefined
18
+ }
19
+
20
+ export function sumCost(messages: Message[]): number {
21
+ let total = 0
22
+ for (const message of messages) {
23
+ if (message.role === 'assistant' && typeof message.cost === 'number') total += message.cost
24
+ }
25
+ return total
26
+ }
27
+
28
+ export function formatCredits(value: number): string {
29
+ if (value <= 0) return '$0.00'
30
+ if (value < 0.01) return `$${value.toFixed(4)}`
31
+ if (value < 1) return `$${value.toFixed(3)}`
32
+ return `$${value.toFixed(2)}`
33
+ }
package/src/lib/daemon.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AppSettings,
4
4
  ChatSession,
5
5
  DaemonSnapshot,
6
+ DeployDetection,
6
7
  DeployEvent,
7
8
  DeployProviderId,
8
9
  DeployRequest,
@@ -12,10 +13,10 @@ import type {
12
13
  ProviderId,
13
14
  ProviderStatus,
14
15
  SecretsSummary,
15
- Skill,
16
16
  WorkspaceFile,
17
17
  WorkspaceInfo,
18
18
  } from '../types'
19
+ import type { InstalledSkill } from '../types'
19
20
  import type { PluginManifest, PluginCapability } from '../../shared/plugin-package.mjs'
20
21
 
21
22
  export interface InstalledPlugin {
@@ -264,10 +265,6 @@ export function setDeployCredentials(provider: DeployProviderId, fields: Record<
264
265
  })
265
266
  }
266
267
 
267
- export function installSkillFromNpmRequest(packageName: string) {
268
- return api<{ skill: Skill }>('/api/skills/install', { method: 'POST', body: JSON.stringify({ package: packageName }) })
269
- }
270
-
271
268
  export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
272
269
  return api('/api/reset', jsonBody({ scope }))
273
270
  }
@@ -457,20 +454,6 @@ export async function requestLLM(
457
454
  return response
458
455
  }
459
456
 
460
- export interface DeployInspection {
461
- path: string
462
- name: string
463
- hasPackageJson: boolean
464
- framework: string
465
- buildCommand: string
466
- outputDir: string
467
- existingOutputs: string[]
468
- }
469
-
470
- export function inspectDeploy(path: string): Promise<DeployInspection> {
471
- return api('/api/deploy/inspect', { method: 'POST', body: JSON.stringify({ path }) })
472
- }
473
-
474
457
  export async function* streamDeploy(
475
458
  request: DeployRequest,
476
459
  signal?: AbortSignal,
@@ -530,3 +513,41 @@ export async function* streamDeploy(
530
513
  }
531
514
 
532
515
  export type { DeployResult, SecretsSummary }
516
+
517
+ export interface SkillUploadFile {
518
+ path: string
519
+ /** base64 */
520
+ data: string
521
+ }
522
+
523
+ export function listSkillsApi(): Promise<{ skills: InstalledSkill[] }> {
524
+ return api('/api/skills')
525
+ }
526
+
527
+ export function uploadSkillFiles(files: SkillUploadFile[]): Promise<{ installed: string[]; skills: InstalledSkill[] }> {
528
+ return api('/api/skills/upload', jsonBody({ files }))
529
+ }
530
+
531
+ export function installSkillNpm(spec: string): Promise<{ installed: string[]; skills: InstalledSkill[] }> {
532
+ return api('/api/skills/npm', jsonBody({ spec }))
533
+ }
534
+
535
+ export function setSkillEnabledApi(name: string, enabled: boolean): Promise<{ skills: InstalledSkill[] }> {
536
+ return api(`/api/skills/${encodeURIComponent(name)}/enabled`, { method: 'PUT', body: JSON.stringify({ enabled }) })
537
+ }
538
+
539
+ export function removeSkillApi(name: string): Promise<{ skills: InstalledSkill[] }> {
540
+ return api(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' })
541
+ }
542
+
543
+ export function readSkillApi(
544
+ name: string,
545
+ file?: string,
546
+ ): Promise<{ name: string; file: string; directory: string; content: string; files?: string[] }> {
547
+ return api('/api/skills/read', jsonBody({ name, file }))
548
+ }
549
+
550
+ export async function detectDeployProject(path: string): Promise<DeployDetection> {
551
+ const result = await api<{ project: DeployDetection }>('/api/deploy/detect', jsonBody({ path }))
552
+ return result.project
553
+ }