@bibobonking/opensol 3.1.4 → 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 +10 -12
  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 +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  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 +40 -12
  59. package/src/lib/retry.ts +41 -8
  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-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  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 {
@@ -249,9 +249,6 @@ export function WorkspacePanel() {
249
249
  return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
250
250
  }, [entries, query])
251
251
  const lineCount = content ? content.split('\n').length : 0
252
- const dirCount = entries.filter((entry) => entry.type === 'dir').length
253
- const fileCount = entries.length - dirCount
254
- const dirPaths = useMemo(() => entries.filter((entry) => entry.type === 'dir').map((entry) => entry.path), [entries])
255
252
 
256
253
  useEffect(() => {
257
254
  if (editor) editorRef.current?.focus()
@@ -272,6 +269,20 @@ export function WorkspacePanel() {
272
269
  return () => window.removeEventListener('keydown', onKey)
273
270
  }, [])
274
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
+
275
286
  if (!open) return null
276
287
 
277
288
  const submitEditor = async () => {
@@ -316,87 +327,82 @@ export function WorkspacePanel() {
316
327
  }
317
328
  }
318
329
 
330
+ const statusTone = (status: string) =>
331
+ status.includes('?') || status.includes('A') ? 'text-success' : status.includes('D') ? 'text-danger' : 'text-warning'
332
+
319
333
  const renderNode = (node: TreeNode, depth: number): ReactNode => {
320
334
  const path = node.entry.path
335
+ const indent = 10 + depth * 14
321
336
  if (node.entry.type === 'dir') {
322
337
  const closed = collapsed.has(path)
338
+ const touched = gitStatus.dirs.has(path)
323
339
  return (
324
340
  <div key={path}>
325
- <div
326
- className="group/row relative flex h-7 items-center rounded-lg transition-colors hover:bg-raised/80"
327
- style={{ paddingLeft: 6 + depth * 14 }}
341
+ <button
342
+ type="button"
343
+ onClick={() =>
344
+ setCollapsed((current) => {
345
+ const next = new Set(current)
346
+ if (next.has(path)) next.delete(path)
347
+ else next.add(path)
348
+ return next
349
+ })
350
+ }
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 }}
353
+ title={path}
354
+ aria-expanded={!closed}
328
355
  >
329
- <button
330
- type="button"
331
- onClick={() =>
332
- setCollapsed((current) => {
333
- const next = new Set(current)
334
- if (next.has(path)) next.delete(path)
335
- else next.add(path)
336
- return next
337
- })
338
- }
339
- className="flex min-w-0 flex-1 items-center gap-1.5 pr-1 text-left text-[11.5px] font-medium text-muted transition-colors hover:text-ink"
340
- title={path}
341
- aria-expanded={!closed}
342
- >
343
- <ChevronRight
344
- size={10}
345
- className={cn('shrink-0 text-faint transition-transform duration-150', !closed && 'rotate-90')}
346
- />
347
- <FileIcon path={path} type="dir" open={!closed} size={13} />
348
- <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
349
- <span className="shrink-0 pr-1 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
350
- {node.children.length}
351
- </span>
352
- </button>
353
- <button
354
- type="button"
355
- onClick={() => void removeEntry(path)}
356
- className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus-visible:opacity-100 group-hover/row:opacity-100"
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} />
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
+ )}
363
+ <span
364
+ role="button"
365
+ tabIndex={0}
366
+ onClick={(event) => {
367
+ event.stopPropagation()
368
+ void removeEntry(path)
369
+ }}
370
+ onKeyDown={(event) => {
371
+ if (event.key === 'Enter') void removeEntry(path)
372
+ }}
373
+ className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
357
374
  aria-label={`Delete ${path}`}
358
- title={`Delete ${path}`}
359
375
  >
360
- <Trash2 size={10} />
361
- </button>
362
- </div>
363
- {!closed && (
376
+ <Trash2 size={11} />
377
+ </span>
378
+ </button>
379
+ {!closed && node.children.length > 0 && (
364
380
  <div className="relative">
365
- <span
366
- aria-hidden
367
- className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/60"
368
- style={{ left: 11 + depth * 14 }}
369
- />
381
+ <span aria-hidden className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/70" style={{ left: indent + 5 }} />
370
382
  {node.children.map((child) => renderNode(child, depth + 1))}
371
383
  </div>
372
384
  )}
373
385
  </div>
374
386
  )
375
387
  }
388
+ const active = activeFile === path
389
+ const status = gitStatus.files.get(path)
376
390
  return (
377
391
  <button
378
392
  key={path}
379
393
  type="button"
380
394
  onClick={() => setActiveFile(path)}
381
395
  className={cn(
382
- 'group/row relative flex h-7 w-full items-center gap-1.5 rounded-lg pr-1.5 text-left font-mono text-[10.5px] transition-colors',
383
- activeFile === path
384
- ? 'bg-accent-soft/80 font-medium text-accent'
385
- : 'text-muted hover:bg-raised/80 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',
386
398
  )}
387
- style={{ paddingLeft: 22 + depth * 14 }}
399
+ style={{ paddingLeft: indent + 13 }}
388
400
  title={path}
389
401
  >
390
- {activeFile === path && (
391
- <span aria-hidden className="absolute left-0 top-1 bottom-1 w-[2px] rounded-full bg-accent" />
392
- )}
393
- <FileIcon path={path} size={12} className={cn(activeFile === path && 'text-accent')} />
394
- <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
395
- {node.entry.size > 0 && (
396
- <span className="shrink-0 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
397
- {formatBytes(node.entry.size)}
398
- </span>
399
- )}
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>}
400
406
  </button>
401
407
  )
402
408
  }
@@ -539,20 +545,6 @@ export function WorkspacePanel() {
539
545
  aria-label="Find a file"
540
546
  />
541
547
  </div>
542
- <div className="flex items-center justify-between gap-2 px-1 pt-1.5 text-[8.5px] text-faint">
543
- <span className="truncate">
544
- {filtered ? `${filtered.length} matches` : `${fileCount} files · ${dirCount} folders`}
545
- </span>
546
- {!filtered && dirCount > 0 && (
547
- <button
548
- type="button"
549
- onClick={() => setCollapsed(collapsed.size > 0 ? new Set() : new Set(dirPaths))}
550
- className="shrink-0 font-medium hover:text-ink"
551
- >
552
- {collapsed.size > 0 ? 'Expand all' : 'Collapse all'}
553
- </button>
554
- )}
555
- </div>
556
548
  {editor && (
557
549
  <div className="mt-1.5 flex items-center gap-1">
558
550
  <input
@@ -587,34 +579,24 @@ export function WorkspacePanel() {
587
579
  <Spinner size={11} /> Reading folder…
588
580
  </p>
589
581
  )}
590
- {filtered ? (
591
- <>
592
- {filtered.map((entry) => (
582
+ {filtered
583
+ ? filtered.map((entry) => (
593
584
  <button
594
585
  key={entry.path}
595
586
  type="button"
596
587
  onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
597
588
  className={cn(
598
- 'group/row relative flex h-7 w-full items-center gap-1.5 rounded-lg px-2 text-left font-mono text-[10.5px] transition-colors',
589
+ 'flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left font-mono text-[10.5px]',
599
590
  activeFile === entry.path
600
- ? 'bg-accent-soft/80 font-medium text-accent'
601
- : 'text-muted hover:bg-raised/80 hover:text-ink',
591
+ ? 'bg-accent-soft font-medium text-accent'
592
+ : 'text-muted hover:bg-raised hover:text-ink',
602
593
  )}
603
594
  >
604
- {activeFile === entry.path && (
605
- <span aria-hidden className="absolute left-0 top-1 bottom-1 w-[2px] rounded-full bg-accent" />
606
- )}
607
- <FileIcon path={entry.path} type={entry.type === 'dir' ? 'dir' : 'file'} size={12} />
595
+ {entry.type === 'dir' ? <FolderIcon name={entry.name} size={15} /> : <FileIcon name={entry.name} size={15} />}
608
596
  <span className="truncate">{entry.path}</span>
609
597
  </button>
610
- ))}
611
- {filtered.length === 0 && (
612
- <p className="px-2 py-4 text-center text-[10.5px] text-faint">No matches in this folder.</p>
613
- )}
614
- </>
615
- ) : (
616
- tree.map((node) => renderNode(node, 0))
617
- )}
598
+ ))
599
+ : tree.map((node) => renderNode(node, 0))}
618
600
  {!loadingTree && !treeError && entries.length === 0 && (
619
601
  <p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
620
602
  )}
@@ -734,7 +716,8 @@ export function WorkspacePanel() {
734
716
  className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-[10px] hover:bg-raised"
735
717
  title={entry.path}
736
718
  >
737
- <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} />
738
721
  <span className="truncate text-muted">{entry.path}</span>
739
722
  </button>
740
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,17 +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'
10
- import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from '../../lib/retry'
9
+ import { messageCost } from '../../lib/credits'
10
+ import { MAX_RETRIES, backoffMs, classifyProviderError } from '../../lib/retry'
11
11
  import type { AdapterResult } from '../../agent/adapters/shared'
12
12
  import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
13
13
  import { resolveToolSetting } from '../mcp-tools'
14
14
  import type { StepContext, StepResult, TurnResult } from '../types'
15
15
 
16
- const MAX_RETRIES = MAX_PROVIDER_RETRIES
17
-
18
16
  function sleep(ms: number, signal: AbortSignal): Promise<void> {
19
17
  return new Promise((resolve) => {
20
18
  const finish = () => {
@@ -111,7 +109,7 @@ export class AgentLoopService extends Service {
111
109
  let turnError: string | undefined
112
110
 
113
111
  try {
114
- // 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.
115
113
  rounds: for (let round = 0; ; round += 1) {
116
114
  totalRounds = round + 1
117
115
  if (controller.signal.aborted) break
@@ -188,12 +186,7 @@ export class AgentLoopService extends Service {
188
186
  settings,
189
187
  model: findModel(settings.provider, activeModelId(settings)),
190
188
  history,
191
- system: this.ctx.systemPrompt.assemble(settings, mode, {
192
- sessionPrompt: current.systemPrompt,
193
- workspacePrompt: current.workspace
194
- ? settings.workspacePrompts[current.workspace]
195
- : settings.workspacePrompts[useWorkspace.getState().root ?? ''],
196
- }),
189
+ system: this.ctx.systemPrompt.assemble(settings, mode, sessionId),
197
190
  mode,
198
191
  signal: controller.signal,
199
192
  handlers: {
@@ -215,39 +208,26 @@ export class AgentLoopService extends Service {
215
208
  } catch (error) {
216
209
  const stopped = isAbortError(error) || controller.signal.aborted
217
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)
218
213
  const retryable =
219
- !stopped &&
220
- attempt < MAX_RETRIES &&
221
- !streamedContent &&
222
- !streamedReasoning &&
223
- isRetryableProviderError(error instanceof Error ? error.message : '')
214
+ !stopped && decision.retry && attempt < MAX_RETRIES && !streamedContent && !streamedReasoning
224
215
  if (retryable) {
225
216
  attempt += 1
226
- const reason = (error instanceof Error ? error.message : 'provider error').replace(/\s+/g, ' ').slice(0, 240)
217
+ const wait = backoffMs(attempt, message)
227
218
  window.dispatchEvent(
228
219
  new CustomEvent('maxcode:notice', {
229
220
  detail: {
230
- message: `${providerName}: ${reason} — 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(retryDelayMs(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)
240
230
  turnError = stopped ? undefined : describeRequestError(error, providerName)
241
- if (turnError) {
242
- window.dispatchEvent(
243
- new CustomEvent('maxcode:notice', {
244
- detail: {
245
- message: `${providerName} failed after ${attempt + 1} attempt${attempt === 0 ? '' : 's'}: ${turnError}`,
246
- tone: 'error',
247
- },
248
- }),
249
- )
250
- }
251
231
  this.ctx.sessions.updateMessage(sessionId, assistantId, {
252
232
  content: streamedContent,
253
233
  reasoning: streamedReasoning,
@@ -266,7 +246,11 @@ export class AgentLoopService extends Service {
266
246
  reasoning: result.reasoning,
267
247
  thinkingBlocks: result.thinkingBlocks,
268
248
  toolCalls: result.toolCalls,
269
- 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
+ ),
270
254
  durationMs: Date.now() - startedAt,
271
255
  stopped: controller.signal.aborted || undefined,
272
256
  })
@@ -331,6 +315,7 @@ export class AgentLoopService extends Service {
331
315
  typeof call.args.task === 'string' ? call.args.task : '',
332
316
  typeof call.args.context === 'string' ? call.args.context : '',
333
317
  controller.signal,
318
+ sessionId,
334
319
  ),
335
320
  ),
336
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 {