@bibobonking/opensol 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/LICENSE +21 -0
  3. package/README.md +24 -20
  4. package/desktop/README.md +1 -1
  5. package/dist/assets/{index-C0yvs6Vl.js → index-BUVnnhRH.js} +1 -1
  6. package/dist/assets/index-CyxlAgKM.js +271 -0
  7. package/dist/assets/index-DFeF90S8.css +1 -0
  8. package/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/server/README.md +15 -68
  11. package/server/daemon.mjs +6 -1
  12. package/server/lib/deploy.mjs +5 -10
  13. package/server/lib/shell.mjs +149 -0
  14. package/server/lib/shell.test.mjs +72 -0
  15. package/server/lib/tools.mjs +8 -44
  16. package/src/agent/adapters/anthropic.ts +2 -3
  17. package/src/agent/adapters/gemini.ts +2 -3
  18. package/src/agent/adapters/openai-responses.ts +2 -3
  19. package/src/agent/adapters/openai.ts +2 -3
  20. package/src/agent/adapters/shared.ts +3 -0
  21. package/src/components/ChatView.tsx +17 -54
  22. package/src/components/Composer.tsx +143 -135
  23. package/src/components/ContextBar.tsx +2 -2
  24. package/src/components/HomeDashboard.tsx +7 -4
  25. package/src/components/MessageItem.tsx +33 -1
  26. package/src/components/ModelDeck.tsx +5 -27
  27. package/src/components/SettingsModal.tsx +8 -0
  28. package/src/components/ToolCallCard.tsx +2 -2
  29. package/src/components/TrajectoryView.tsx +1 -1
  30. package/src/components/composer/PlusMenu.tsx +117 -0
  31. package/src/components/composer/Popover.tsx +109 -0
  32. package/src/components/composer/WorkspacePicker.tsx +102 -0
  33. package/src/core/plugins/tools/shell.ts +2 -2
  34. package/src/core/plugins/tools/subagent.ts +18 -8
  35. package/src/core/services/agent-loop.ts +45 -43
  36. package/src/core/services/compaction.ts +289 -22
  37. package/src/core/services/llm.ts +6 -2
  38. package/src/core/services/subagents.ts +191 -3
  39. package/src/core/services/system-prompt.ts +35 -7
  40. package/src/core/services/tools.ts +2 -1
  41. package/src/core/types.ts +5 -0
  42. package/src/lib/compact-plan.test.mjs +62 -0
  43. package/src/lib/compact-plan.ts +188 -0
  44. package/src/lib/subagent-types.ts +54 -0
  45. package/src/lib/tool-meta.ts +40 -0
  46. package/src/store/settings.ts +3 -0
  47. package/src/store/ui.ts +12 -0
  48. package/src/types.ts +26 -0
  49. package/shared/plugin-builder.test.mjs +0 -43
  50. package/shared/plugin-package.d.mts +0 -34
  51. package/shared/plugin-package.mjs +0 -197
  52. package/shared/plugin-package.test.mjs +0 -94
  53. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,62 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { planCompaction, collectFiles, clip, chunkParts, previousSummary, transcriptParts, tokensOf } from './compact-plan.ts'
4
+
5
+ const user = (id, text = 'x'.repeat(300)) => ({ id, role: 'user', content: text, createdAt: 0 })
6
+ const asst = (id, calls = [], content = 'ok') => ({ id, role: 'assistant', content, reasoning: '', toolCalls: calls, round: 0, createdAt: 0 })
7
+ const tool = (id, name = 'read_file', result = 'r'.repeat(300)) => ({ id, role: 'tool', call: { id: `${id}c`, name, args: {} }, status: 'done', result, round: 0, createdAt: 0 })
8
+
9
+ const turn = (n) => [user(`u${n}`), asst(`a${n}`, [{ id: `c${n}`, name: 'read_file', args: {} }]), tool(`t${n}`), asst(`b${n}`)]
10
+
11
+ test('keeps the newest turns that fit and cuts at a user message', () => {
12
+ const messages = [1, 2, 3, 4, 5].flatMap(turn)
13
+ const plan = planCompaction(messages, tokensOf(turn(1)) * 2 + 10)
14
+ assert.ok(plan)
15
+ assert.equal(plan.tail[0].role, 'user')
16
+ assert.equal(plan.tail.filter((m) => m.role === 'user').length, 2)
17
+ assert.equal(plan.head.length + plan.tail.length, messages.length)
18
+ })
19
+
20
+ test('never separates a tool call from its result', () => {
21
+ const messages = [1, 2, 3].flatMap(turn)
22
+ const plan = planCompaction(messages, 1)
23
+ assert.ok(plan)
24
+ assert.equal(plan.tail.length, 4) // only the newest turn, whole
25
+ })
26
+
27
+ test('returns null when there is nothing to cut', () => {
28
+ assert.equal(planCompaction(turn(1), 10_000), null)
29
+ assert.equal(planCompaction([], 100), null)
30
+ })
31
+
32
+ test('tracks written files once, most recent last', () => {
33
+ const calls = (name, path) => asst('a', [{ id: path + name, name, args: { path } }])
34
+ const files = collectFiles([calls('write_file', 'a.ts'), calls('read_file', 'z.ts'), calls('edit_file', 'b.ts'), calls('edit_file', 'a.ts')], ['old.ts'])
35
+ assert.deepEqual(files, ['old.ts', 'b.ts', 'a.ts'])
36
+ })
37
+
38
+ test('clip keeps head and tail', () => {
39
+ const text = `START${'m'.repeat(1000)}END`
40
+ const out = clip(text, 100)
41
+ assert.ok(out.startsWith('START') && out.endsWith('END') && out.length < 200)
42
+ assert.equal(clip('short', 100), 'short')
43
+ })
44
+
45
+ test('chunks never split a part and respect the size', () => {
46
+ const chunks = chunkParts(['a'.repeat(50), 'b'.repeat(50), 'c'.repeat(50)], 120)
47
+ assert.equal(chunks.length, 2)
48
+ assert.ok(chunks[0].includes('a') && chunks[0].includes('b') && !chunks[0].includes('c'))
49
+ })
50
+
51
+ test('previousSummary reads a [task, summary] pair', () => {
52
+ const summary = { ...asst('s'), content: 'SUM', compaction: { files: ['f.ts'], turns: 2, toolCalls: 3, at: 1, trigger: 'auto' } }
53
+ assert.deepEqual(previousSummary([user('u'), summary, user('u2')]), { text: 'SUM', files: ['f.ts'], skip: 2 })
54
+ assert.equal(previousSummary([user('u'), asst('a')]), null)
55
+ })
56
+
57
+ test('read_file output is trimmed harder than shell output in transcripts', () => {
58
+ const big = 'q'.repeat(5000)
59
+ const [read] = transcriptParts([tool('t1', 'read_file', big)])
60
+ const [shell] = transcriptParts([tool('t2', 'shell', big)])
61
+ assert.ok(read.length < shell.length)
62
+ })
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Pure helpers for smart compaction (no stores, no network, so node:test can run them).
3
+ *
4
+ * Strategy: keep the most recent turns verbatim, summarise everything older, and carry the
5
+ * previous summary forward instead of summarising a summary. File paths touched are tracked
6
+ * deterministically, so the model cannot forget them.
7
+ */
8
+ import type { AssistantMessage, Message, ToolMessage, UserMessage } from '../types'
9
+
10
+ const CHARS_PER_TOKEN = 3
11
+
12
+ export const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'rewrite_file'])
13
+
14
+ export function messageTokens(message: Message): number {
15
+ if (message.role === 'user') {
16
+ const extra = (message.attachments ?? []).reduce((sum, item) => sum + (item.text?.length ?? 0), 0)
17
+ return Math.ceil((message.content.length + extra) / CHARS_PER_TOKEN) + 4
18
+ }
19
+ if (message.role === 'assistant') {
20
+ const calls = message.toolCalls.reduce((sum, call) => sum + JSON.stringify(call.args).length + call.name.length + 10, 0)
21
+ return Math.ceil((message.content.length + message.reasoning.length + calls) / CHARS_PER_TOKEN) + 4
22
+ }
23
+ return Math.ceil((message.result ?? '').length / CHARS_PER_TOKEN) + 4
24
+ }
25
+
26
+ export function tokensOf(messages: Message[]): number {
27
+ return messages.reduce((sum, message) => sum + messageTokens(message), 0)
28
+ }
29
+
30
+ /** Keeps the start and the end of long text, because the end of a log usually matters most. */
31
+ export function clip(text: string, limit: number, tailRatio = 0.35): string {
32
+ if (text.length <= limit) return text
33
+ const tail = Math.floor(limit * tailRatio)
34
+ const head = limit - tail
35
+ return `${text.slice(0, head)}\n… [${text.length - limit} characters omitted] …\n${text.slice(text.length - tail)}`
36
+ }
37
+
38
+ export function isSummaryMessage(message: Message | undefined): message is AssistantMessage {
39
+ return Boolean(message && message.role === 'assistant' && message.compaction)
40
+ }
41
+
42
+ export interface CompactionPlan {
43
+ /** Messages [0, cut) are summarised; [cut, end) stay verbatim. */
44
+ cut: number
45
+ head: Message[]
46
+ tail: Message[]
47
+ /** The cut had to land inside the running turn because older turns alone were too small. */
48
+ midTurn: boolean
49
+ }
50
+
51
+ /**
52
+ * Chooses where to cut. The tail always starts at a user message (so provider alternation stays
53
+ * valid and tool calls never lose their results) and is the newest run of turns that fits in
54
+ * `keepTokens`. The newest turn is always kept. Returns null when there is nothing worth cutting.
55
+ */
56
+ export function planCompaction(messages: Message[], keepTokens: number, minHeadMessages = 4): CompactionPlan | null {
57
+ const userIndexes: number[] = []
58
+ messages.forEach((message, index) => {
59
+ if (message.role === 'user') userIndexes.push(index)
60
+ })
61
+ if (userIndexes.length === 0) return null
62
+
63
+ // Walk turns from the newest backwards while they fit in the keep budget.
64
+ let cut = userIndexes[userIndexes.length - 1]
65
+ let kept = tokensOf(messages.slice(cut))
66
+ for (let i = userIndexes.length - 2; i >= 0; i -= 1) {
67
+ const turnTokens = tokensOf(messages.slice(userIndexes[i], userIndexes[i + 1]))
68
+ if (kept + turnTokens > keepTokens) break
69
+ kept += turnTokens
70
+ cut = userIndexes[i]
71
+ }
72
+
73
+ // Skip an existing summary pair (task message + summary) at the very start: it is always head.
74
+ if (cut < minHeadMessages) return null
75
+ return { cut, head: messages.slice(0, cut), tail: messages.slice(cut), midTurn: false }
76
+ }
77
+
78
+ /** Previous summary text, when the head starts with `[task user message, summary]`. */
79
+ export function previousSummary(head: Message[]): { text: string; files: string[]; skip: number } | null {
80
+ const summary = head[1]
81
+ if (head[0]?.role === 'user' && isSummaryMessage(summary)) {
82
+ return { text: summary.content, files: summary.compaction?.files ?? [], skip: 2 }
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** Paths written or edited by tool calls, newest last, de-duplicated. */
88
+ export function collectFiles(messages: Message[], seed: string[] = []): string[] {
89
+ const seen = new Set(seed)
90
+ const ordered = [...seed]
91
+ for (const message of messages) {
92
+ if (message.role !== 'assistant') continue
93
+ for (const call of message.toolCalls) {
94
+ const path = typeof call.args.path === 'string' ? call.args.path : ''
95
+ if (!path || !WRITE_TOOLS.has(call.name)) continue
96
+ if (seen.has(path)) {
97
+ ordered.splice(ordered.indexOf(path), 1)
98
+ }
99
+ seen.add(path)
100
+ ordered.push(path)
101
+ }
102
+ }
103
+ return ordered
104
+ }
105
+
106
+ function toolResultLimit(message: ToolMessage): number {
107
+ const name = message.call.name
108
+ if (message.status === 'error') return 1500
109
+ if (name === 'read_file') return 700 // contents can be re-read; keep only the shape
110
+ if (name === 'write_file' || name === 'edit_file' || name === 'rewrite_file') return 400
111
+ if (name === 'shell') return 2500
112
+ return 1500
113
+ }
114
+
115
+ function callSummary(args: Record<string, unknown>): string {
116
+ const text = JSON.stringify(args)
117
+ return text.length > 600 ? `${text.slice(0, 600)}…` : text
118
+ }
119
+
120
+ /** Transcript lines, one entry per message, so they can be chunked without splitting a message. */
121
+ export function transcriptParts(messages: Message[]): string[] {
122
+ const parts: string[] = []
123
+ for (const message of messages) {
124
+ if (message.role === 'user') {
125
+ const names = (message.attachments ?? []).map((item) => item.name)
126
+ parts.push(`[USER]\n${clip(message.content, 6000)}${names.length ? `\n[Attachments: ${names.join(', ')}]` : ''}`)
127
+ } else if (message.role === 'assistant') {
128
+ const lines: string[] = []
129
+ if (message.content.trim()) lines.push(clip(message.content.trim(), 4000))
130
+ for (const call of message.toolCalls) lines.push(`→ ${call.name} ${callSummary(call.args)}`)
131
+ if (lines.length) parts.push(`[ASSISTANT]\n${lines.join('\n')}`)
132
+ } else {
133
+ const status = message.status === 'done' ? 'ok' : message.status
134
+ parts.push(`[RESULT ${message.call.name} · ${status}]\n${clip(message.result ?? '', toolResultLimit(message))}`)
135
+ }
136
+ }
137
+ return parts
138
+ }
139
+
140
+ /** Groups transcript parts into chunks under `maxChars`; one oversized part gets its own chunk. */
141
+ export function chunkParts(parts: string[], maxChars: number): string[] {
142
+ const chunks: string[] = []
143
+ let current: string[] = []
144
+ let size = 0
145
+ for (const part of parts) {
146
+ if (current.length && size + part.length > maxChars) {
147
+ chunks.push(current.join('\n\n---\n\n'))
148
+ current = []
149
+ size = 0
150
+ }
151
+ current.push(part)
152
+ size += part.length + 9
153
+ }
154
+ if (current.length) chunks.push(current.join('\n\n---\n\n'))
155
+ return chunks
156
+ }
157
+
158
+ /** Used when the model cannot summarise: a lossy but deterministic record of the session. */
159
+ export function fallbackSummary(head: Message[], previous: string, files: string[]): string {
160
+ const asks = head
161
+ .filter((message): message is UserMessage => message.role === 'user')
162
+ .map((message) => `- ${clip(message.content.trim().replace(/\s+/g, ' '), 300)}`)
163
+ const lastAnswer = [...head]
164
+ .reverse()
165
+ .find((message): message is AssistantMessage => message.role === 'assistant' && Boolean(message.content.trim()))
166
+ const toolCount = head.filter((message) => message.role === 'tool').length
167
+ return [
168
+ previous ? `## Earlier summary\n${previous}` : '',
169
+ '## User requests so far',
170
+ asks.slice(-12).join('\n') || '- (none)',
171
+ lastAnswer ? `## Last assistant conclusion\n${clip(lastAnswer.content.trim(), 1500)}` : '',
172
+ `## Activity\n${toolCount} tool calls were made. This summary was built without the model, so details may be missing: re-read files before relying on them.`,
173
+ files.length ? `## Files changed\n${files.map((file) => `- ${file}`).join('\n')}` : '',
174
+ ]
175
+ .filter(Boolean)
176
+ .join('\n\n')
177
+ }
178
+
179
+ /** A short, verbatim look at the newest steps, for compaction in the middle of a running turn. */
180
+ export function recentSteps(messages: Message[], count = 4): string {
181
+ const steps: string[] = []
182
+ for (let i = messages.length - 1; i >= 0 && steps.length < count; i -= 1) {
183
+ const message = messages[i]
184
+ if (message.role !== 'tool') continue
185
+ steps.push(`• ${message.call.name} ${callSummary(message.call.args)} → ${message.status}\n${clip(message.result ?? '', 1200)}`)
186
+ }
187
+ return steps.reverse().join('\n\n')
188
+ }
@@ -0,0 +1,54 @@
1
+ import type { ToolName } from '../types'
2
+
3
+ export type SubagentType = 'explore' | 'research' | 'review'
4
+
5
+ export interface SubagentSpec {
6
+ label: string
7
+ /** One line for the main agent's tool description. */
8
+ summary: string
9
+ /** Built-in tools this kind of subagent may call. All are read-only in Plan mode. */
10
+ tools: ToolName[]
11
+ /** Hard cap on model rounds; the subagent is then forced to report. */
12
+ maxRounds: number
13
+ guidance: string[]
14
+ }
15
+
16
+ export const SUBAGENT_TYPES: Record<SubagentType, SubagentSpec> = {
17
+ explore: {
18
+ label: 'Explore',
19
+ summary: 'fast, local: find where something lives, trace a flow or a bug through the codebase',
20
+ tools: ['read_file', 'list_files', 'grep', 'shell', 'git', 'skill'],
21
+ maxRounds: 24,
22
+ guidance: [
23
+ 'Search wide first (grep, list_files), then read only the files that matter.',
24
+ 'Run independent searches in the same round instead of one after another.',
25
+ 'Report exact paths with line numbers and a short quote or code reference for each claim.',
26
+ ],
27
+ },
28
+ research: {
29
+ label: 'Research',
30
+ summary: 'look things up on the web (docs, release notes, errors) and cross-check with the local code',
31
+ tools: ['read_file', 'list_files', 'grep', 'web_search', 'read_url', 'skill'],
32
+ maxRounds: 18,
33
+ guidance: [
34
+ 'Prefer primary sources (official docs, changelogs, source repositories). Open the pages you cite.',
35
+ 'Cross-check important claims with a second source or with the local code.',
36
+ 'Report each finding with its URL, and say plainly what you could not confirm.',
37
+ ],
38
+ },
39
+ review: {
40
+ label: 'Review',
41
+ summary: 'critically review code or a diff for bugs, regressions, missing tests and risky changes',
42
+ tools: ['read_file', 'list_files', 'grep', 'shell', 'git', 'skill'],
43
+ maxRounds: 30,
44
+ guidance: [
45
+ 'Start from `git status` and `git diff` when the task is about recent changes; otherwise read the named files.',
46
+ 'Check call sites and edge cases, not just the changed lines. Verify a suspected bug by reading the code it touches.',
47
+ 'Report findings ordered by severity, each with file:line, why it is a problem and a concrete fix. Say "no issues found" if that is the truth.',
48
+ ],
49
+ },
50
+ }
51
+
52
+ export function subagentType(value: unknown): SubagentType {
53
+ return value === 'research' || value === 'review' ? value : 'explore'
54
+ }
@@ -0,0 +1,40 @@
1
+ import type { BuiltinToolName, ToolName } from '../types'
2
+
3
+ export function toolLabel(name: ToolName): string {
4
+ if (name.startsWith('mcp__')) return name.split('__').slice(2).join(' ').replace(/_/g, ' ') || 'MCP tool'
5
+ const labels: Record<BuiltinToolName, string> = {
6
+ shell: 'Shell',
7
+ read_file: 'Read file',
8
+ list_files: 'List files',
9
+ write_file: 'Write file',
10
+ edit_file: 'Edit file',
11
+ rewrite_file: 'Rewrite file',
12
+ grep: 'Grep',
13
+ web_search: 'Web search',
14
+ read_url: 'Read URL',
15
+ git: 'Git',
16
+ subagent: 'Subagent',
17
+ skill: 'Skill',
18
+ }
19
+ return labels[name as BuiltinToolName] ?? 'Tool'
20
+ }
21
+
22
+ export function toolSummary(name: ToolName, args: Record<string, unknown>): string {
23
+ const value =
24
+ name === 'shell'
25
+ ? args.command
26
+ : name === 'grep'
27
+ ? args.pattern
28
+ : name === 'web_search'
29
+ ? args.query
30
+ : name === 'read_url'
31
+ ? args.url
32
+ : name === 'git'
33
+ ? args.action
34
+ : name === 'subagent'
35
+ ? args.task
36
+ : name === 'skill'
37
+ ? [args.name, args.file].filter(Boolean).join(' / ')
38
+ : (args.path ?? '')
39
+ return typeof value === 'string' ? value : ''
40
+ }
@@ -79,6 +79,7 @@ const DEFAULTS: AppSettings = {
79
79
  workDetail: 'detailed',
80
80
  autoCollapseWork: true,
81
81
  aiSessionTitles: true,
82
+ showMascot: false,
82
83
  }
83
84
 
84
85
  const EFFORTS = new Set<Effort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
@@ -187,6 +188,7 @@ function buildSettings(persisted?: Partial<AppSettings>): AppSettings {
187
188
  workDetail: saved.workDetail === 'concise' ? 'concise' : 'detailed',
188
189
  autoCollapseWork: saved.autoCollapseWork !== false,
189
190
  aiSessionTitles: saved.aiSessionTitles !== false,
191
+ showMascot: saved.showMascot === true,
190
192
  workspacePrompts: sanitizeWorkspacePrompts(saved.workspacePrompts),
191
193
  }
192
194
  }
@@ -216,6 +218,7 @@ function persistedPatch(patch: Partial<AppSettings>): Partial<AppSettings> {
216
218
  'workDetail',
217
219
  'autoCollapseWork',
218
220
  'aiSessionTitles',
221
+ 'showMascot',
219
222
  'workspacePrompts',
220
223
  ]
221
224
  for (const key of keys) {
package/src/store/ui.ts CHANGED
@@ -6,9 +6,21 @@ interface RuntimeState {
6
6
  /** Sessions whose title the AI is writing right now. */
7
7
  titling: Record<string, boolean>
8
8
  setTitling: (sessionId: string, active: boolean) => void
9
+ /** Sessions being compacted right now. */
10
+ compacting: Record<string, boolean>
11
+ setCompacting: (sessionId: string, active: boolean) => void
9
12
  }
10
13
 
11
14
  export const useRuntime = create<RuntimeState>()((set) => ({
15
+ compacting: {},
16
+ setCompacting: (sessionId, active) =>
17
+ set((state) => {
18
+ if (Boolean(state.compacting[sessionId]) === active) return state
19
+ const compacting = { ...state.compacting }
20
+ if (active) compacting[sessionId] = true
21
+ else delete compacting[sessionId]
22
+ return { compacting }
23
+ }),
12
24
  titling: {},
13
25
  setTitling: (sessionId, active) =>
14
26
  set((state) => {
package/src/types.ts CHANGED
@@ -150,6 +150,8 @@ export interface AppSettings {
150
150
  autoCollapseWork: boolean
151
151
  /** Let the model name a session after its first answer. */
152
152
  aiSessionTitles: boolean
153
+ /** Show the pixel mascot on the home screen and above the composer. */
154
+ showMascot: boolean
153
155
  }
154
156
 
155
157
  export interface Attachment {
@@ -203,6 +205,20 @@ export interface AssistantMessage {
203
205
  createdAt: number
204
206
  /** Tool calls in this round already have follow-up tool messages. */
205
207
  round: number
208
+ /** Set on the summary message written by compaction; `content` is the summary. */
209
+ compaction?: CompactionMeta
210
+ }
211
+
212
+ export interface CompactionMeta {
213
+ /** Files written or edited before this point, tracked from tool calls (not model-written). */
214
+ files: string[]
215
+ /** User turns and tool calls folded into the summary. */
216
+ turns: number
217
+ toolCalls: number
218
+ at: number
219
+ trigger: 'auto' | 'manual' | 'mid-turn'
220
+ /** The model was unavailable, so the summary was built without it. */
221
+ fallback?: boolean
206
222
  }
207
223
 
208
224
  /** Line-level change report for a file edit: +added, −removed and where it started (1-based). */
@@ -358,6 +374,11 @@ export interface DaemonInfo {
358
374
  dataDir: string
359
375
  home: string
360
376
  platform: string
377
+ /** process.platform of the daemon: win32, darwin or linux. */
378
+ os?: string
379
+ arch?: string
380
+ /** Shell the agent's commands run in, e.g. "zsh on macOS (arm64)". */
381
+ shell?: string
361
382
  node: string
362
383
  }
363
384
 
@@ -412,6 +433,11 @@ export interface DaemonSnapshot {
412
433
  dataDir: string
413
434
  home: string
414
435
  platform: string
436
+ /** process.platform of the daemon: win32, darwin or linux. */
437
+ os?: string
438
+ arch?: string
439
+ /** Shell the agent's commands run in, e.g. "zsh on macOS (arm64)". */
440
+ shell?: string
415
441
  node: string
416
442
  settings: Partial<AppSettings>
417
443
  chats: { sessions: ChatSession[]; activeId: string | null }
@@ -1,43 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { buildPluginArchive, readPluginArchive, validatePluginManifest } from './plugin-package.mjs'
4
-
5
- const manifest = {
6
- id: 'demo-plugin',
7
- name: 'Demo plugin',
8
- version: '1.0.0',
9
- apiVersion: 1,
10
- entry: 'main.js',
11
- stylesheet: 'main.css',
12
- capabilities: ['ui.commands'],
13
- domains: [],
14
- }
15
-
16
- test('a built package reopens with its sources and assets intact', () => {
17
- const archive = buildPluginArchive({
18
- manifest,
19
- files: { 'main.js': 'maxcode.log("hi")', 'main.css': '.a{}', 'assets/icon.txt': 'icon' },
20
- })
21
- const reopened = readPluginArchive(archive)
22
- assert.equal(reopened.manifest.id, 'demo-plugin')
23
- assert.equal(reopened.files['main.js'], 'maxcode.log("hi")')
24
- assert.equal(reopened.files['main.css'], '.a{}')
25
- assert.equal(reopened.files['assets/icon.txt'], 'icon')
26
- })
27
-
28
- test('a manifest may omit domains entirely — a plugin with no network declares none', () => {
29
- const { domains, ...withoutDomains } = manifest
30
- assert.deepEqual(validatePluginManifest(withoutDomains).domains, [])
31
- assert.deepEqual(readPluginArchive(buildPluginArchive({
32
- manifest: withoutDomains,
33
- files: { 'main.js': 'maxcode.log("hi")', 'main.css': '' },
34
- })).manifest.domains, [])
35
- assert.throws(() => validatePluginManifest({ ...manifest, domains: 'api.example.com' }), /domains must be an array/)
36
- })
37
-
38
- test('the builder rejects an invalid manifest before packaging', () => {
39
- assert.throws(() => validatePluginManifest({ ...manifest, id: 'Bad Id' }), /Invalid plugin package/)
40
- assert.throws(() => validatePluginManifest({ ...manifest, entry: 'main.ts' }), /entry must be JavaScript/)
41
- assert.throws(() => validatePluginManifest({ ...manifest, capabilities: ['shell.exec'] }), /capabilit/i)
42
- assert.throws(() => validatePluginManifest({ ...manifest, domains: ['https://api.example.com'] }), /domain/i)
43
- })
@@ -1,34 +0,0 @@
1
- export type PluginCapability =
2
- | 'workspace.list'
3
- | 'workspace.read'
4
- | 'workspace.write'
5
- | 'workspace.edit'
6
- | 'ui.commands'
7
- | 'ui.panels'
8
- | 'ui.themes'
9
- | 'network.request'
10
-
11
- export interface PluginManifest {
12
- id: string
13
- name: string
14
- version: string
15
- apiVersion: 1 | 2
16
- entry: string
17
- stylesheet: string
18
- capabilities: PluginCapability[]
19
- domains: string[]
20
- }
21
-
22
- export interface PluginPackageSources {
23
- manifest: PluginManifest
24
- files: Record<string, string | Uint8Array>
25
- }
26
-
27
- export interface PluginPackage {
28
- manifest: PluginManifest
29
- files: Record<string, string>
30
- }
31
-
32
- export function validatePluginManifest(value: unknown): PluginManifest
33
- export function buildPluginArchive(sources: PluginPackageSources): Uint8Array
34
- export function readPluginArchive(bytes: Uint8Array): PluginPackage