@bibobonking/opensol 3.1.2 → 3.1.4

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 (44) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/assets/index-B1pQC9Fe.js +288 -0
  3. package/dist/assets/index-qLr4aZVC.css +1 -0
  4. package/dist/favicon.svg +11 -5
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/public/favicon.svg +11 -5
  8. package/server/daemon.mjs +42 -4
  9. package/server/lib/deploy.mjs +78 -7
  10. package/server/lib/skills.mjs +128 -0
  11. package/server/lib/tools.mjs +40 -0
  12. package/src/agent/adapters/openai-responses.ts +5 -1
  13. package/src/agent/adapters/openai.ts +15 -2
  14. package/src/agent/prompt.ts +4 -7
  15. package/src/agent/tools.ts +1 -0
  16. package/src/components/DeployModal.tsx +77 -15
  17. package/src/components/FileIcon.tsx +108 -0
  18. package/src/components/HomeDashboard.tsx +9 -2
  19. package/src/components/Logo.tsx +11 -5
  20. package/src/components/ModelDeck.tsx +12 -2
  21. package/src/components/SettingsModal.tsx +144 -199
  22. package/src/components/Sidebar.tsx +4 -5
  23. package/src/components/SystemPromptModal.tsx +168 -0
  24. package/src/components/TopBar.tsx +19 -0
  25. package/src/components/WorkspacePanel.tsx +94 -46
  26. package/src/core/kernel.ts +2 -0
  27. package/src/core/plugins/tools/skill.ts +101 -0
  28. package/src/core/services/agent-loop.ts +26 -28
  29. package/src/core/services/system-prompt.ts +15 -13
  30. package/src/core/services/tools.ts +1 -0
  31. package/src/lib/daemon.ts +19 -0
  32. package/src/lib/permissions.ts +1 -1
  33. package/src/lib/retry.test.mjs +17 -0
  34. package/src/lib/retry.ts +13 -0
  35. package/src/lib/skills.test.mjs +22 -0
  36. package/src/lib/skills.ts +93 -0
  37. package/src/lib/usage.ts +12 -0
  38. package/src/store/chats.ts +1 -0
  39. package/src/store/settings.ts +21 -9
  40. package/src/types.ts +14 -1
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/dist/assets/index-CeN54A2N.css +0 -1
  43. package/dist/assets/index-fhIO9a7_.js +0 -318
  44. package/src/data/skills.ts +0 -68
@@ -2,6 +2,7 @@ import {
2
2
  FolderOpen,
3
3
  ListChecks,
4
4
  Menu,
5
+ MessageSquareCode,
5
6
  Moon,
6
7
  PanelLeftOpen,
7
8
  Rocket,
@@ -19,6 +20,8 @@ import { cn } from '../lib/utils'
19
20
  import { themeIsDark } from '../lib/themes'
20
21
  import { LogoMark } from './Logo'
21
22
  import { ContextBar } from './ContextBar'
23
+ import { SystemPromptModal } from './SystemPromptModal'
24
+ import { useState } from 'react'
22
25
 
23
26
  export type ChatViewMode = 'chat' | 'trajectory'
24
27
 
@@ -44,7 +47,11 @@ export function TopBar({
44
47
  const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
45
48
  const setDeployOpen = useUi((state) => state.setDeployOpen)
46
49
  const workspace = useWorkspace((state) => state.info)
50
+ const workspaceRoot = useWorkspace((state) => state.root)
47
51
  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()))
48
55
 
49
56
  // Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
50
57
  const isDark = themeIsDark(
@@ -126,6 +133,17 @@ export function TopBar({
126
133
  <ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
127
134
  </div>
128
135
  )}
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>
129
147
  <button
130
148
  type="button"
131
149
  onClick={() => setWorkspaceOpen(!workspaceOpen)}
@@ -177,6 +195,7 @@ export function TopBar({
177
195
  <Settings size={16} />
178
196
  </button>
179
197
  </div>
198
+ <SystemPromptModal session={session} open={promptOpen} onClose={() => setPromptOpen(false)} />
180
199
  </header>
181
200
  )
182
201
  }
@@ -28,6 +28,7 @@ import { useWorkspace } from '../store/workspace'
28
28
  import { PreviewPane } from './PreviewPane'
29
29
  import { SessionsTab } from './SessionsTab'
30
30
  import { useChats } from '../store/chats'
31
+ import { FileIcon } from './FileIcon'
31
32
  import { Spinner } from './ui'
32
33
 
33
34
  interface TreeNode {
@@ -248,6 +249,9 @@ export function WorkspacePanel() {
248
249
  return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
249
250
  }, [entries, query])
250
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])
251
255
 
252
256
  useEffect(() => {
253
257
  if (editor) editorRef.current?.focus()
@@ -318,44 +322,54 @@ export function WorkspacePanel() {
318
322
  const closed = collapsed.has(path)
319
323
  return (
320
324
  <div key={path}>
321
- <button
322
- type="button"
323
- onClick={() =>
324
- setCollapsed((current) => {
325
- const next = new Set(current)
326
- if (next.has(path)) next.delete(path)
327
- else next.add(path)
328
- return next
329
- })
330
- }
331
- 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"
332
- style={{ paddingLeft: 8 + depth * 13 }}
333
- title={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 }}
334
328
  >
335
- <ChevronRight size={10} className={cn('shrink-0 text-faint transition-transform', !closed && 'rotate-90')} />
336
- {closed ? (
337
- <Folder size={12} className="shrink-0 text-faint" />
338
- ) : (
339
- <FolderOpen size={12} className="shrink-0 text-faint" />
340
- )}
341
- <span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
342
- <span
343
- role="button"
344
- tabIndex={0}
345
- onClick={(event) => {
346
- event.stopPropagation()
347
- void removeEntry(path)
348
- }}
349
- onKeyDown={(event) => {
350
- if (event.key === 'Enter') void removeEntry(path)
351
- }}
352
- className="hidden h-5 w-5 items-center justify-center rounded text-faint hover:text-danger group-hover:flex"
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"
353
357
  aria-label={`Delete ${path}`}
358
+ title={`Delete ${path}`}
354
359
  >
355
360
  <Trash2 size={10} />
356
- </span>
357
- </button>
358
- {!closed && node.children.map((child) => renderNode(child, depth + 1))}
361
+ </button>
362
+ </div>
363
+ {!closed && (
364
+ <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
+ />
370
+ {node.children.map((child) => renderNode(child, depth + 1))}
371
+ </div>
372
+ )}
359
373
  </div>
360
374
  )
361
375
  }
@@ -365,14 +379,24 @@ export function WorkspacePanel() {
365
379
  type="button"
366
380
  onClick={() => setActiveFile(path)}
367
381
  className={cn(
368
- 'flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left font-mono text-[10.5px] transition-colors',
369
- activeFile === path ? 'bg-accent-soft font-medium text-accent' : 'text-muted hover:bg-raised hover:text-ink',
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',
370
386
  )}
371
- style={{ paddingLeft: 8 + depth * 13 + 10 }}
387
+ style={{ paddingLeft: 22 + depth * 14 }}
372
388
  title={path}
373
389
  >
374
- <FileText size={11} className={cn('shrink-0', activeFile === path ? 'text-accent' : 'text-faint')} />
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')} />
375
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
+ )}
376
400
  </button>
377
401
  )
378
402
  }
@@ -515,6 +539,20 @@ export function WorkspacePanel() {
515
539
  aria-label="Find a file"
516
540
  />
517
541
  </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>
518
556
  {editor && (
519
557
  <div className="mt-1.5 flex items-center gap-1">
520
558
  <input
@@ -549,24 +587,34 @@ export function WorkspacePanel() {
549
587
  <Spinner size={11} /> Reading folder…
550
588
  </p>
551
589
  )}
552
- {filtered
553
- ? filtered.map((entry) => (
590
+ {filtered ? (
591
+ <>
592
+ {filtered.map((entry) => (
554
593
  <button
555
594
  key={entry.path}
556
595
  type="button"
557
596
  onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
558
597
  className={cn(
559
- 'flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left font-mono text-[10.5px]',
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',
560
599
  activeFile === entry.path
561
- ? 'bg-accent-soft font-medium text-accent'
562
- : 'text-muted hover:bg-raised hover:text-ink',
600
+ ? 'bg-accent-soft/80 font-medium text-accent'
601
+ : 'text-muted hover:bg-raised/80 hover:text-ink',
563
602
  )}
564
603
  >
565
- {entry.type === 'dir' ? <Folder size={11} /> : <FileText size={11} />}
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} />
566
608
  <span className="truncate">{entry.path}</span>
567
609
  </button>
568
- ))
569
- : tree.map((node) => renderNode(node, 0))}
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
+ )}
570
618
  {!loadingTree && !treeError && entries.length === 0 && (
571
619
  <p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
572
620
  )}
@@ -17,6 +17,7 @@ import * as toolGrep from './plugins/tools/grep'
17
17
  import * as toolGit from './plugins/tools/git'
18
18
  import * as toolWeb from './plugins/tools/web'
19
19
  import * as toolSubagent from './plugins/tools/subagent'
20
+ import * as toolSkill from './plugins/tools/skill'
20
21
 
21
22
  export function createAppKernel(): Context {
22
23
  const ctx = new Context()
@@ -40,6 +41,7 @@ export function createAppKernel(): Context {
40
41
  ctx.plugin(toolGit)
41
42
  ctx.plugin(toolWeb)
42
43
  ctx.plugin(toolSubagent)
44
+ ctx.plugin(toolSkill)
43
45
 
44
46
  return ctx
45
47
  }
@@ -0,0 +1,101 @@
1
+ import type { Context } from 'cordis'
2
+ import { executeDaemonTool } from '../../../lib/daemon'
3
+ import type { Skill } from '../../../types'
4
+ import type { ToolResult } from '../../types'
5
+
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
+ }
14
+
15
+ export const name = 'tool-skill'
16
+ export const inject = ['tools']
17
+
18
+ export function apply(ctx: Context) {
19
+ const unregister = ctx.tools.register({
20
+ name: 'skill',
21
+ 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.',
23
+ parameters: {
24
+ type: 'object',
25
+ 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.' },
29
+ },
30
+ required: ['action'],
31
+ additionalProperties: false,
32
+ },
33
+ 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,
53
+ }
54
+ }
55
+
56
+ if (action === 'read') {
57
+ const files = Object.keys(target.files ?? {})
58
+ 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'),
69
+ isError: false,
70
+ }
71
+ }
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
+ },
98
+ })
99
+
100
+ return () => unregister()
101
+ }
@@ -4,17 +4,16 @@ 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'
7
8
  import { getProviderName } from '../../data/providers'
8
9
  import { describeRequestError } from '../../lib/sse'
10
+ import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from '../../lib/retry'
9
11
  import type { AdapterResult } from '../../agent/adapters/shared'
10
12
  import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
11
13
  import { resolveToolSetting } from '../mcp-tools'
12
14
  import type { StepContext, StepResult, TurnResult } from '../types'
13
15
 
14
- const MAX_ROUNDS = 16
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
16
+ const MAX_RETRIES = MAX_PROVIDER_RETRIES
18
17
 
19
18
  function sleep(ms: number, signal: AbortSignal): Promise<void> {
20
19
  return new Promise((resolve) => {
@@ -112,7 +111,8 @@ export class AgentLoopService extends Service {
112
111
  let turnError: string | undefined
113
112
 
114
113
  try {
115
- rounds: for (let round = 0; round < MAX_ROUNDS; round += 1) {
114
+ // No round cap: the turn ends when the model stops calling tools or the user stops it.
115
+ rounds: for (let round = 0; ; round += 1) {
116
116
  totalRounds = round + 1
117
117
  if (controller.signal.aborted) break
118
118
  let current = this.ctx.sessions.current(sessionId)
@@ -188,7 +188,12 @@ export class AgentLoopService extends Service {
188
188
  settings,
189
189
  model: findModel(settings.provider, activeModelId(settings)),
190
190
  history,
191
- system: this.ctx.systemPrompt.assemble(settings, mode),
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
+ }),
192
197
  mode,
193
198
  signal: controller.signal,
194
199
  handlers: {
@@ -215,23 +220,34 @@ export class AgentLoopService extends Service {
215
220
  attempt < MAX_RETRIES &&
216
221
  !streamedContent &&
217
222
  !streamedReasoning &&
218
- RETRYABLE.test(error instanceof Error ? error.message : '')
223
+ isRetryableProviderError(error instanceof Error ? error.message : '')
219
224
  if (retryable) {
220
225
  attempt += 1
226
+ const reason = (error instanceof Error ? error.message : 'provider error').replace(/\s+/g, ' ').slice(0, 240)
221
227
  window.dispatchEvent(
222
228
  new CustomEvent('maxcode:notice', {
223
229
  detail: {
224
- message: `${providerName} is busy. Retrying (${attempt}/${MAX_RETRIES})…`,
230
+ message: `${providerName}: ${reason} — retrying ${attempt}/${MAX_RETRIES}…`,
225
231
  tone: 'info',
226
232
  },
227
233
  }),
228
234
  )
229
- await sleep(1200 * attempt * attempt, controller.signal)
235
+ await sleep(retryDelayMs(attempt), controller.signal)
230
236
  if (controller.signal.aborted) continue
231
237
  continue
232
238
  }
233
239
  if (flushTimer !== undefined) window.clearTimeout(flushTimer)
234
240
  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
+ }
235
251
  this.ctx.sessions.updateMessage(sessionId, assistantId, {
236
252
  content: streamedContent,
237
253
  reasoning: streamedReasoning,
@@ -250,7 +266,7 @@ export class AgentLoopService extends Service {
250
266
  reasoning: result.reasoning,
251
267
  thinkingBlocks: result.thinkingBlocks,
252
268
  toolCalls: result.toolCalls,
253
- usage: usage.input || usage.output ? usage : result.usage,
269
+ usage: usage.input || usage.output || usage.cost ? usage : result.usage,
254
270
  durationMs: Date.now() - startedAt,
255
271
  stopped: controller.signal.aborted || undefined,
256
272
  })
@@ -384,24 +400,6 @@ export class AgentLoopService extends Service {
384
400
  break
385
401
  }
386
402
  }
387
-
388
- const finalSession = this.ctx.sessions.current(sessionId)
389
- if (!controller.signal.aborted && finalSession) {
390
- const last = finalSession.messages[finalSession.messages.length - 1]
391
- if (last?.role === 'tool') {
392
- const message: AssistantMessage = {
393
- id: createId('msg'),
394
- role: 'assistant',
395
- content: '',
396
- reasoning: '',
397
- toolCalls: [],
398
- round: MAX_ROUNDS,
399
- createdAt: Date.now(),
400
- error: `Stopped after ${MAX_ROUNDS} tool rounds. Send another message to continue.`,
401
- }
402
- this.ctx.sessions.addMessage(sessionId, message)
403
- }
404
- }
405
403
  } finally {
406
404
  this.controllers.delete(sessionId)
407
405
  useRuntime.getState().setStreaming(sessionId, false)
@@ -43,28 +43,30 @@ export class SystemPromptService extends Service {
43
43
  return () => this.customSections.delete(name)
44
44
  }
45
45
 
46
- assemble(settings: AppSettings, mode: AgentMode = settings.mode): string {
46
+ assemble(
47
+ settings: AppSettings,
48
+ mode: AgentMode = settings.mode,
49
+ overrides: { sessionPrompt?: string; workspacePrompt?: string } = {},
50
+ ): string {
47
51
  const sections: PromptSection[] = []
48
52
 
49
- // 1. Base instruction
50
- if (settings.systemPrompt?.trim()) {
51
- sections.push({ name: 'base', priority: 10, content: settings.systemPrompt.trim() })
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 })
52
57
  }
53
58
 
54
- // 2. Loaded skills
55
- const skills = settings.skills.filter((skill) => skill.enabled)
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)
56
61
  if (skills.length > 0) {
57
62
  sections.push({
58
63
  name: 'skills',
59
64
  priority: 20,
60
65
  content: [
61
- '## Loaded skills',
62
- 'Apply these skills when the task matches their purpose. They refine the working rules above; they never override safety, tool permissions or plan mode.',
63
- ...skills.map(
64
- (skill) =>
65
- `<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
66
- ),
67
- ].join('\n\n'),
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'),
68
70
  })
69
71
  }
70
72
 
@@ -15,6 +15,7 @@ const READ_ONLY_TOOLS = new Set<ToolName>([
15
15
  'grep',
16
16
  'web_search',
17
17
  'read_url',
18
+ 'skill',
18
19
  ])
19
20
 
20
21
  export function toolAllowedInMode(name: ToolName, mode: AgentMode): boolean {
package/src/lib/daemon.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  ProviderId,
13
13
  ProviderStatus,
14
14
  SecretsSummary,
15
+ Skill,
15
16
  WorkspaceFile,
16
17
  WorkspaceInfo,
17
18
  } from '../types'
@@ -263,6 +264,10 @@ export function setDeployCredentials(provider: DeployProviderId, fields: Record<
263
264
  })
264
265
  }
265
266
 
267
+ export function installSkillFromNpmRequest(packageName: string) {
268
+ return api<{ skill: Skill }>('/api/skills/install', { method: 'POST', body: JSON.stringify({ package: packageName }) })
269
+ }
270
+
266
271
  export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
267
272
  return api('/api/reset', jsonBody({ scope }))
268
273
  }
@@ -452,6 +457,20 @@ export async function requestLLM(
452
457
  return response
453
458
  }
454
459
 
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
+
455
474
  export async function* streamDeploy(
456
475
  request: DeployRequest,
457
476
  signal?: AbortSignal,
@@ -21,7 +21,7 @@ export const PERMISSION_MODES: Array<{ value: PermissionMode; label: string; des
21
21
  ]
22
22
 
23
23
  const WRITE_TOOLS: BuiltinToolName[] = ['write_file', 'edit_file', 'rewrite_file']
24
- const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent']
24
+ const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent', 'skill']
25
25
 
26
26
  export function permissionMode(tools: AppSettings['tools']): PermissionMode {
27
27
  const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
@@ -0,0 +1,17 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from './retry.ts'
4
+
5
+ test('provider hiccups are retried, auth and validation errors are not', () => {
6
+ assert.equal(isRetryableProviderError('429 Too Many Requests'), true)
7
+ assert.equal(isRetryableProviderError('503 Service Unavailable'), true)
8
+ assert.equal(isRetryableProviderError('socket hang up: ECONNRESET'), true)
9
+ assert.equal(isRetryableProviderError('401 Unauthorized: invalid api key'), false)
10
+ assert.equal(isRetryableProviderError('400 invalid request: unknown field'), false)
11
+ })
12
+
13
+ test('five retries with a capped backoff', () => {
14
+ assert.equal(MAX_PROVIDER_RETRIES, 5)
15
+ assert.equal(retryDelayMs(1), 1200)
16
+ assert.equal(retryDelayMs(5), 8000)
17
+ })
@@ -0,0 +1,13 @@
1
+ /** Automatic retries for provider hiccups: overload, rate limits, 5xx and dropped connections. */
2
+ export const MAX_PROVIDER_RETRIES = 5
3
+
4
+ const RETRYABLE =
5
+ /\b(408|409|425|429|500|502|503|504|522|524|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror|econnreset|etimedout|enotfound|eai_again/i
6
+
7
+ export function isRetryableProviderError(message: string): boolean {
8
+ return RETRYABLE.test(message)
9
+ }
10
+
11
+ export function retryDelayMs(attempt: number): number {
12
+ return Math.min(1200 * attempt * attempt, 8000)
13
+ }
@@ -0,0 +1,22 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { parseSkillMarkdown } from './skills.ts'
4
+
5
+ test('frontmatter supplies the name and description', () => {
6
+ const skill = parseSkillMarkdown('---\nname: API reviewer\ndescription: "Checks a diff"\n---\n# Ignored\n\nBody text.', 'fallback')
7
+ assert.equal(skill.name, 'API reviewer')
8
+ assert.equal(skill.description, 'Checks a diff')
9
+ assert.equal(skill.instructions, '# Ignored\n\nBody text.')
10
+ })
11
+
12
+ test('a plain SKILL.md falls back to its heading and first paragraph', () => {
13
+ const skill = parseSkillMarkdown('# Deploy helper\n\nRuns the release checklist.\n\n## Steps\n1. build', 'fallback')
14
+ assert.equal(skill.name, 'Deploy helper')
15
+ assert.equal(skill.description, 'Runs the release checklist.')
16
+ })
17
+
18
+ test('an empty fallback markdown still returns a usable skill', () => {
19
+ const skill = parseSkillMarkdown('', 'my-skill')
20
+ assert.equal(skill.name, 'my-skill')
21
+ assert.equal(skill.description, 'Installed from a folder')
22
+ })