@codeany/open-agent-sdk 0.1.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 (57) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +388 -0
  4. package/examples/01-simple-query.ts +43 -0
  5. package/examples/02-multi-tool.ts +44 -0
  6. package/examples/03-multi-turn.ts +39 -0
  7. package/examples/04-prompt-api.ts +29 -0
  8. package/examples/05-custom-system-prompt.ts +26 -0
  9. package/examples/06-mcp-server.ts +49 -0
  10. package/examples/07-custom-tools.ts +87 -0
  11. package/examples/08-official-api-compat.ts +38 -0
  12. package/examples/09-subagents.ts +48 -0
  13. package/examples/10-permissions.ts +40 -0
  14. package/examples/11-custom-mcp-tools.ts +101 -0
  15. package/examples/web/index.html +365 -0
  16. package/examples/web/server.ts +157 -0
  17. package/package.json +60 -0
  18. package/src/agent.ts +425 -0
  19. package/src/engine.ts +520 -0
  20. package/src/hooks.ts +261 -0
  21. package/src/index.ts +376 -0
  22. package/src/mcp/client.ts +150 -0
  23. package/src/sdk-mcp-server.ts +78 -0
  24. package/src/session.ts +227 -0
  25. package/src/tool-helper.ts +127 -0
  26. package/src/tools/agent-tool.ts +153 -0
  27. package/src/tools/ask-user.ts +79 -0
  28. package/src/tools/bash.ts +75 -0
  29. package/src/tools/config-tool.ts +89 -0
  30. package/src/tools/cron-tools.ts +153 -0
  31. package/src/tools/edit.ts +74 -0
  32. package/src/tools/glob.ts +77 -0
  33. package/src/tools/grep.ts +168 -0
  34. package/src/tools/index.ts +232 -0
  35. package/src/tools/lsp-tool.ts +163 -0
  36. package/src/tools/mcp-resource-tools.ts +125 -0
  37. package/src/tools/notebook-edit.ts +93 -0
  38. package/src/tools/plan-tools.ts +88 -0
  39. package/src/tools/read.ts +73 -0
  40. package/src/tools/send-message.ts +96 -0
  41. package/src/tools/task-tools.ts +290 -0
  42. package/src/tools/team-tools.ts +128 -0
  43. package/src/tools/todo-tool.ts +112 -0
  44. package/src/tools/tool-search.ts +87 -0
  45. package/src/tools/types.ts +62 -0
  46. package/src/tools/web-fetch.ts +66 -0
  47. package/src/tools/web-search.ts +86 -0
  48. package/src/tools/worktree-tools.ts +140 -0
  49. package/src/tools/write.ts +42 -0
  50. package/src/types.ts +459 -0
  51. package/src/utils/compact.ts +206 -0
  52. package/src/utils/context.ts +191 -0
  53. package/src/utils/fileCache.ts +148 -0
  54. package/src/utils/messages.ts +196 -0
  55. package/src/utils/retry.ts +140 -0
  56. package/src/utils/tokens.ts +122 -0
  57. package/tsconfig.json +19 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Example 7: Custom Tools
3
+ *
4
+ * Shows how to define and use custom tools alongside built-in tools.
5
+ *
6
+ * Run: npx tsx examples/07-custom-tools.ts
7
+ */
8
+ import { createAgent, getAllBaseTools, defineTool } from '../src/index.js'
9
+
10
+ const weatherTool = defineTool({
11
+ name: 'GetWeather',
12
+ description: 'Get current weather for a city. Returns temperature and conditions.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ city: { type: 'string', description: 'City name (e.g., "Tokyo", "London")' },
17
+ },
18
+ required: ['city'],
19
+ },
20
+ isReadOnly: true,
21
+ isConcurrencySafe: true,
22
+ async call(input) {
23
+ const temps: Record<string, number> = {
24
+ tokyo: 22, london: 14, beijing: 25, 'new york': 18, paris: 16,
25
+ }
26
+ const temp = temps[input.city?.toLowerCase()] ?? 20
27
+ return `Weather in ${input.city}: ${temp}°C, partly cloudy`
28
+ },
29
+ })
30
+
31
+ const calculatorTool = defineTool({
32
+ name: 'Calculator',
33
+ description: 'Evaluate a mathematical expression. Use ** for exponentiation.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ expression: { type: 'string', description: 'Math expression (e.g., "42 * 17 + 3", "2 ** 10")' },
38
+ },
39
+ required: ['expression'],
40
+ },
41
+ isReadOnly: true,
42
+ isConcurrencySafe: true,
43
+ async call(input) {
44
+ try {
45
+ const result = Function(`'use strict'; return (${input.expression})`)()
46
+ return `${input.expression} = ${result}`
47
+ } catch (e: any) {
48
+ return { data: `Error: ${e.message}`, is_error: true }
49
+ }
50
+ },
51
+ })
52
+
53
+ async function main() {
54
+ console.log('--- Example 7: Custom Tools ---\n')
55
+
56
+ const builtinTools = getAllBaseTools()
57
+ const allTools = [...builtinTools, weatherTool, calculatorTool]
58
+
59
+ const agent = createAgent({
60
+ model: process.env.CODEANY_MODEL || 'claude-sonnet-4-6',
61
+ maxTurns: 10,
62
+ tools: allTools,
63
+ })
64
+
65
+ console.log(`Loaded ${allTools.length} tools (${builtinTools.length} built-in + 2 custom)\n`)
66
+
67
+ for await (const event of agent.query(
68
+ 'What is the weather in Tokyo and London? Also calculate 2**10 * 3. Be brief.',
69
+ )) {
70
+ const msg = event as any
71
+ if (msg.type === 'assistant') {
72
+ for (const block of msg.message?.content || []) {
73
+ if (block.type === 'tool_use') {
74
+ console.log(`[${block.name}] ${JSON.stringify(block.input)}`)
75
+ }
76
+ if (block.type === 'text' && block.text.trim()) {
77
+ console.log(`\n${block.text}`)
78
+ }
79
+ }
80
+ }
81
+ if (msg.type === 'result') {
82
+ console.log(`\n--- ${msg.subtype} ---`)
83
+ }
84
+ }
85
+ }
86
+
87
+ main().catch(console.error)
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Example 8: Official SDK-Compatible API
3
+ *
4
+ * Demonstrates the query() function with the same API pattern
5
+ * as open-agent-sdk. Drop-in compatible.
6
+ *
7
+ * Run: npx tsx examples/08-official-api-compat.ts
8
+ */
9
+ import { query } from '../src/index.js'
10
+
11
+ async function main() {
12
+ console.log('--- Example 8: Official SDK-Compatible API ---\n')
13
+
14
+ // Standard SDK query pattern
15
+ for await (const message of query({
16
+ prompt: 'What files are in this directory? Be brief.',
17
+ options: {
18
+ allowedTools: ['Bash', 'Glob'],
19
+ permissionMode: 'bypassPermissions',
20
+ },
21
+ })) {
22
+ const msg = message as any
23
+
24
+ if (msg.type === 'assistant' && msg.message?.content) {
25
+ for (const block of msg.message.content) {
26
+ if ('text' in block && block.text) {
27
+ console.log(block.text)
28
+ } else if ('name' in block) {
29
+ console.log(`Tool: ${block.name}`)
30
+ }
31
+ }
32
+ } else if (msg.type === 'result') {
33
+ console.log(`\nDone: ${msg.subtype}`)
34
+ }
35
+ }
36
+ }
37
+
38
+ main().catch(console.error)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Example 9: Subagents
3
+ *
4
+ * Define specialized subagents that the main agent can delegate
5
+ * tasks to. Matches the official SDK's agents option.
6
+ *
7
+ * Run: npx tsx examples/09-subagents.ts
8
+ */
9
+ import { query } from '../src/index.js'
10
+
11
+ async function main() {
12
+ console.log('--- Example 9: Subagents ---\n')
13
+
14
+ for await (const message of query({
15
+ prompt: 'Use the code-reviewer agent to review src/agent.ts',
16
+ options: {
17
+ allowedTools: ['Read', 'Glob', 'Grep', 'Agent'],
18
+ agents: {
19
+ 'code-reviewer': {
20
+ description: 'Expert code reviewer for quality and security reviews.',
21
+ prompt:
22
+ 'Analyze code quality and suggest improvements. Focus on ' +
23
+ 'security, performance, and maintainability. Be concise.',
24
+ tools: ['Read', 'Glob', 'Grep'],
25
+ },
26
+ },
27
+ },
28
+ })) {
29
+ const msg = message as any
30
+
31
+ if (msg.type === 'assistant') {
32
+ for (const block of msg.message?.content || []) {
33
+ if ('text' in block && block.text?.trim()) {
34
+ console.log(block.text)
35
+ }
36
+ if ('name' in block) {
37
+ console.log(`[${block.name}] ${JSON.stringify(block.input || {}).slice(0, 80)}`)
38
+ }
39
+ }
40
+ }
41
+
42
+ if (msg.type === 'result') {
43
+ console.log(`\n--- ${msg.subtype} ---`)
44
+ }
45
+ }
46
+ }
47
+
48
+ main().catch(console.error)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Example 10: Permissions and Allowed Tools
3
+ *
4
+ * Shows how to restrict which tools the agent can use.
5
+ * Creates a read-only agent that can analyze but not modify code.
6
+ *
7
+ * Run: npx tsx examples/10-permissions.ts
8
+ */
9
+ import { query } from '../src/index.js'
10
+
11
+ async function main() {
12
+ console.log('--- Example 10: Read-Only Agent ---\n')
13
+
14
+ // Read-only agent: can only use Read, Glob, Grep
15
+ for await (const message of query({
16
+ prompt: 'Review the code in src/agent.ts for best practices. Be concise.',
17
+ options: {
18
+ allowedTools: ['Read', 'Glob', 'Grep'],
19
+ },
20
+ })) {
21
+ const msg = message as any
22
+
23
+ if (msg.type === 'assistant') {
24
+ for (const block of msg.message?.content || []) {
25
+ if ('text' in block && block.text?.trim()) {
26
+ console.log(block.text)
27
+ }
28
+ if ('name' in block) {
29
+ console.log(`[${block.name}]`)
30
+ }
31
+ }
32
+ }
33
+
34
+ if (msg.type === 'result') {
35
+ console.log(`\n--- ${msg.subtype} ---`)
36
+ }
37
+ }
38
+ }
39
+
40
+ main().catch(console.error)
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Example 11: Custom Tools with tool() + createSdkMcpServer()
3
+ *
4
+ * Shows the Zod-based tool() helper and in-process MCP server creation.
5
+ * This is the recommended way to add custom tools.
6
+ *
7
+ * Run: npx tsx examples/11-custom-mcp-tools.ts
8
+ */
9
+ import { z } from 'zod'
10
+ import { query, tool, createSdkMcpServer } from '../src/index.js'
11
+
12
+ // Define tools using Zod schemas for type-safe input validation
13
+ const getTemperature = tool(
14
+ 'get_temperature',
15
+ 'Get the current temperature at a location',
16
+ {
17
+ city: z.string().describe('City name'),
18
+ unit: z.enum(['celsius', 'fahrenheit']).default('celsius').describe('Temperature unit'),
19
+ },
20
+ async ({ city, unit }) => {
21
+ // Mock weather data
22
+ const temps: Record<string, number> = {
23
+ tokyo: 22, london: 14, paris: 16, 'new york': 18, beijing: 25,
24
+ }
25
+ const tempC = temps[city.toLowerCase()] ?? 20
26
+ const temp = unit === 'fahrenheit' ? tempC * 9 / 5 + 32 : tempC
27
+ const symbol = unit === 'fahrenheit' ? '°F' : '°C'
28
+
29
+ return {
30
+ content: [{ type: 'text' as const, text: `Temperature in ${city}: ${temp}${symbol}` }],
31
+ }
32
+ },
33
+ { annotations: { readOnlyHint: true } },
34
+ )
35
+
36
+ const convertUnits = tool(
37
+ 'convert_units',
38
+ 'Convert between measurement units',
39
+ {
40
+ value: z.number().describe('Value to convert'),
41
+ from_unit: z.string().describe('Source unit'),
42
+ to_unit: z.string().describe('Target unit'),
43
+ },
44
+ async ({ value, from_unit, to_unit }) => {
45
+ const conversions: Record<string, Record<string, (v: number) => number>> = {
46
+ km: { miles: (v) => v * 0.621371, m: (v) => v * 1000 },
47
+ miles: { km: (v) => v * 1.60934, m: (v) => v * 1609.34 },
48
+ kg: { lbs: (v) => v * 2.20462, g: (v) => v * 1000 },
49
+ lbs: { kg: (v) => v * 0.453592, g: (v) => v * 453.592 },
50
+ }
51
+
52
+ const fn = conversions[from_unit]?.[to_unit]
53
+ if (!fn) {
54
+ return {
55
+ content: [{ type: 'text' as const, text: `Cannot convert from ${from_unit} to ${to_unit}` }],
56
+ isError: true,
57
+ }
58
+ }
59
+
60
+ const result = fn(value)
61
+ return {
62
+ content: [{ type: 'text' as const, text: `${value} ${from_unit} = ${result.toFixed(2)} ${to_unit}` }],
63
+ }
64
+ },
65
+ )
66
+
67
+ // Bundle tools into an in-process MCP server
68
+ const utilityServer = createSdkMcpServer({
69
+ name: 'utilities',
70
+ version: '1.0.0',
71
+ tools: [getTemperature, convertUnits],
72
+ })
73
+
74
+ async function main() {
75
+ console.log('--- Example 11: Custom MCP Tools (tool + createSdkMcpServer) ---\n')
76
+
77
+ for await (const message of query({
78
+ prompt: 'What is the temperature in Tokyo and Paris? Also convert 10 km to miles. Be brief.',
79
+ options: {
80
+ mcpServers: { utilities: utilityServer as any },
81
+ allowedTools: ['mcp__utilities__*'],
82
+ permissionMode: 'bypassPermissions',
83
+ },
84
+ })) {
85
+ const msg = message as any
86
+
87
+ if (msg.type === 'assistant' && msg.message?.content) {
88
+ for (const block of msg.message.content) {
89
+ if ('text' in block && block.text?.trim()) {
90
+ console.log(block.text)
91
+ } else if ('name' in block) {
92
+ console.log(`[${block.name}] ${JSON.stringify(block.input || {})}`)
93
+ }
94
+ }
95
+ } else if (msg.type === 'result') {
96
+ console.log(`\nDone: ${msg.subtype} (cost: $${msg.total_cost_usd?.toFixed(4) || '0'})`)
97
+ }
98
+ }
99
+ }
100
+
101
+ main().catch(console.error)
@@ -0,0 +1,365 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Open Agent SDK</title>
7
+ <style>
8
+ *{margin:0;padding:0;box-sizing:border-box}
9
+ :root{
10
+ --bg:#f5f6f8;--surface:#fff;--border:#e5e7eb;
11
+ --text:#1a1a1a;--text2:#6b7280;--accent:#111;
12
+ --user-bg:#111;--user-fg:#fff;
13
+ --tool-bg:#f3f4f6;--tool-border:#e0e1e4;
14
+ --think-bg:#fffbeb;--think-border:#fde68a;
15
+ --ok-bg:#ecfdf5;--ok-border:#86efac;--ok-text:#166534;
16
+ --err-bg:#fef2f2;--err-border:#fca5a5;--err-text:#991b1b;
17
+ --radius:16px;
18
+ }
19
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
20
+ background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column}
21
+
22
+ /* Header */
23
+ header{display:flex;align-items:center;justify-content:space-between;
24
+ padding:14px 20px;background:var(--surface);border-bottom:1px solid var(--border)}
25
+ header h1{font-size:16px;font-weight:600}
26
+ header button{width:34px;height:34px;border-radius:50%;border:1px solid var(--border);
27
+ background:var(--surface);font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center}
28
+ header button:hover{background:var(--bg)}
29
+
30
+ /* Chat */
31
+ #chat{flex:1;overflow-y:auto;padding:24px 16px}
32
+ #chat-inner{max-width:800px;margin:0 auto;display:flex;flex-direction:column;gap:20px}
33
+
34
+ /* Welcome */
35
+ #welcome{text-align:center;padding:60px 0 30px}
36
+ #welcome h2{font-size:22px;font-weight:600;margin-bottom:22px;color:var(--text)}
37
+ .suggestions{display:grid;grid-template-columns:1fr 1fr;gap:10px;max-width:500px;margin:0 auto}
38
+ .suggestions button{padding:12px 16px;border:1px solid var(--border);border-radius:12px;
39
+ background:var(--surface);cursor:pointer;text-align:left;font-size:13px;color:var(--text);transition:border-color .15s}
40
+ .suggestions button:hover{border-color:#999}
41
+ .suggestions span{margin-right:6px}
42
+
43
+ /* Message rows */
44
+ .msg-row{display:flex}
45
+ .msg-row.user{justify-content:flex-end}
46
+ .msg-row.assistant{justify-content:flex-start}
47
+
48
+ /* Bubbles */
49
+ .bubble-user{background:var(--user-bg);color:var(--user-fg);padding:10px 16px;
50
+ border-radius:18px 18px 4px 18px;max-width:75%;font-size:14px;line-height:1.55;white-space:pre-wrap}
51
+ .bubble-assistant{max-width:85%;font-size:14.5px;line-height:1.65}
52
+ .bubble-assistant p{margin-bottom:8px}
53
+ .bubble-assistant p:last-child{margin-bottom:0}
54
+ .bubble-assistant strong{font-weight:600}
55
+ .bubble-assistant code{background:#f0f1f3;padding:1px 5px;border-radius:4px;font-size:13px}
56
+ .bubble-assistant pre{background:#1e1e1e;color:#d4d4d4;padding:14px 16px;border-radius:10px;
57
+ overflow-x:auto;margin:8px 0;font-size:13px;line-height:1.5}
58
+ .bubble-assistant pre code{background:none;padding:0;color:inherit}
59
+ .bubble-assistant ul,.bubble-assistant ol{padding-left:22px;margin:6px 0}
60
+ .bubble-assistant li{margin:3px 0}
61
+
62
+ /* Tool call card */
63
+ .tool-card{background:var(--tool-bg);border:1px solid var(--tool-border);border-radius:10px;
64
+ padding:10px 14px;margin:8px 0;font-size:13px}
65
+ .tool-card .tool-hd{display:flex;align-items:center;gap:6px;font-weight:600;margin-bottom:4px}
66
+ .tool-card .tool-icon{font-size:15px}
67
+ .tool-card .tool-input{color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
68
+ .tool-result{margin:4px 0 8px 12px;padding:8px 12px;border-left:3px solid var(--tool-border);
69
+ font-size:12.5px;color:var(--text2);max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
70
+
71
+ /* Thinking */
72
+ .think-box{background:var(--think-bg);border:1px solid var(--think-border);border-radius:10px;
73
+ padding:10px 14px;margin:8px 0;font-size:13px;font-style:italic;color:#92400e}
74
+
75
+ /* Result banner */
76
+ .result-banner{border-radius:10px;padding:10px 16px;font-size:13px;margin:8px 0;display:flex;align-items:center;gap:8px}
77
+ .result-banner.ok{background:var(--ok-bg);border:1px solid var(--ok-border);color:var(--ok-text)}
78
+ .result-banner.err{background:var(--err-bg);border:1px solid var(--err-border);color:var(--err-text)}
79
+
80
+ /* Typing indicator */
81
+ .typing{display:flex;align-items:center;gap:4px;padding:8px 0}
82
+ .typing span{width:7px;height:7px;background:#aaa;border-radius:50%;animation:bounce .6s infinite alternate}
83
+ .typing span:nth-child(2){animation-delay:.15s}
84
+ .typing span:nth-child(3){animation-delay:.3s}
85
+ @keyframes bounce{to{transform:translateY(-6px);opacity:.4}}
86
+
87
+ /* Input area */
88
+ #input-area{padding:12px 16px 16px;background:var(--surface);border-top:1px solid var(--border)}
89
+ #input-wrap{max-width:800px;margin:0 auto;display:flex;align-items:flex-end;gap:10px;
90
+ border:1px solid var(--border);border-radius:26px;padding:6px 8px 6px 18px;background:var(--surface);
91
+ transition:border-color .2s}
92
+ #input-wrap:focus-within{border-color:#999}
93
+ #prompt{flex:1;border:none;outline:none;font-size:14px;line-height:1.5;resize:none;
94
+ min-height:24px;max-height:180px;font-family:inherit;background:transparent}
95
+ #send-btn{width:38px;height:38px;border-radius:50%;border:none;background:var(--accent);
96
+ color:#fff;font-size:18px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;
97
+ transition:opacity .15s}
98
+ #send-btn:disabled{opacity:.35;cursor:default}
99
+
100
+ @keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
101
+ .msg-row,.tool-card,.tool-result,.think-box,.result-banner{animation:fadeIn .2s ease}
102
+ </style>
103
+ </head>
104
+ <body>
105
+
106
+ <header>
107
+ <h1>Open Agent SDK</h1>
108
+ <button onclick="newSession()" title="New session">+</button>
109
+ </header>
110
+
111
+ <div id="chat">
112
+ <div id="chat-inner">
113
+ <div id="welcome">
114
+ <h2>What can I do for you?</h2>
115
+ <div class="suggestions">
116
+ <button onclick="useSuggestion(this)"><span>&#128193;</span>List files in this project</button>
117
+ <button onclick="useSuggestion(this)"><span>&#128196;</span>Read package.json</button>
118
+ <button onclick="useSuggestion(this)"><span>&#128202;</span>Count lines of code</button>
119
+ <button onclick="useSuggestion(this)"><span>&#128187;</span>Show system info</button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div id="input-area">
126
+ <div id="input-wrap">
127
+ <textarea id="prompt" rows="1" placeholder="Assign a task or ask anything" onkeydown="handleKey(event)" oninput="autoResize()"></textarea>
128
+ <button id="send-btn" onclick="sendMessage()">&#8593;</button>
129
+ </div>
130
+ </div>
131
+
132
+ <script>
133
+ const chatInner = document.getElementById('chat-inner');
134
+ const chatEl = document.getElementById('chat');
135
+ const promptEl = document.getElementById('prompt');
136
+ const sendBtn = document.getElementById('send-btn');
137
+ const welcomeEl = document.getElementById('welcome');
138
+
139
+ let streaming = false;
140
+
141
+ const TOOL_ICONS = {
142
+ Bash:'&#128736;',Read:'&#128196;',Write:'&#128221;',Edit:'&#9998;',
143
+ Glob:'&#128269;',Grep:'&#128270;',WebFetch:'&#127760;',WebSearch:'&#128269;',
144
+ Agent:'&#129302;',NotebookEdit:'&#128211;',TaskCreate:'&#128203;',
145
+ };
146
+
147
+ /* ---- helpers ---- */
148
+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
149
+
150
+ function renderMarkdown(raw){
151
+ let h = esc(raw);
152
+ // code blocks
153
+ h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (_,lang,code) =>
154
+ `<pre><code class="lang-${lang}">${code.trim()}</code></pre>`);
155
+ // inline code
156
+ h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
157
+ // bold
158
+ h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
159
+ // unordered lists
160
+ h = h.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
161
+ h = h.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>'+m+'</ul>');
162
+ // ordered lists
163
+ h = h.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
164
+ // paragraphs
165
+ h = h.replace(/\n{2,}/g, '</p><p>');
166
+ h = h.replace(/\n/g, '<br>');
167
+ return '<p>'+h+'</p>';
168
+ }
169
+
170
+ function scrollToBottom(){
171
+ requestAnimationFrame(()=>{ chatEl.scrollTop = chatEl.scrollHeight; });
172
+ }
173
+
174
+ function autoResize(){
175
+ promptEl.style.height = 'auto';
176
+ promptEl.style.height = Math.min(promptEl.scrollHeight, 180)+'px';
177
+ }
178
+
179
+ /* ---- actions ---- */
180
+ function handleKey(e){
181
+ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendMessage(); }
182
+ }
183
+
184
+ function useSuggestion(btn){
185
+ const text = btn.textContent.replace(/^.\s*/u,'');
186
+ promptEl.value = text;
187
+ sendMessage();
188
+ }
189
+
190
+ async function newSession(){
191
+ await fetch('/api/new',{method:'POST'});
192
+ chatInner.innerHTML = '';
193
+ chatInner.appendChild(welcomeEl);
194
+ welcomeEl.style.display = '';
195
+ }
196
+
197
+ async function sendMessage(){
198
+ const text = promptEl.value.trim();
199
+ if(!text || streaming) return;
200
+ streaming = true;
201
+ sendBtn.disabled = true;
202
+ promptEl.value = '';
203
+ autoResize();
204
+
205
+ // hide welcome
206
+ welcomeEl.style.display = 'none';
207
+
208
+ // user bubble
209
+ const uRow = document.createElement('div');
210
+ uRow.className = 'msg-row user';
211
+ uRow.innerHTML = `<div class="bubble-user">${esc(text)}</div>`;
212
+ chatInner.appendChild(uRow);
213
+ scrollToBottom();
214
+
215
+ // typing indicator
216
+ const typingEl = document.createElement('div');
217
+ typingEl.className = 'msg-row assistant';
218
+ typingEl.innerHTML = '<div class="typing"><span></span><span></span><span></span></div>';
219
+ chatInner.appendChild(typingEl);
220
+ scrollToBottom();
221
+
222
+ // assistant wrapper
223
+ const aRow = document.createElement('div');
224
+ aRow.className = 'msg-row assistant';
225
+ const aBubble = document.createElement('div');
226
+ aBubble.className = 'bubble-assistant';
227
+ aRow.appendChild(aBubble);
228
+
229
+ let textAcc = '';
230
+ let textEl = null;
231
+ let typingRemoved = false;
232
+
233
+ function ensureTextEl(){
234
+ if(!textEl){
235
+ textEl = document.createElement('div');
236
+ aBubble.appendChild(textEl);
237
+ }
238
+ }
239
+
240
+ try{
241
+ const resp = await fetch('/api/chat',{
242
+ method:'POST',
243
+ headers:{'Content-Type':'application/json'},
244
+ body: JSON.stringify({message:text}),
245
+ });
246
+
247
+ if(!resp.ok){
248
+ throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
249
+ }
250
+
251
+ // Two code paths: streaming (ReadableStream) or fallback (text)
252
+ if(resp.body && typeof resp.body.getReader === 'function'){
253
+ // --- Streaming path ---
254
+ const reader = resp.body.getReader();
255
+ const decoder = new TextDecoder();
256
+ let buf = '';
257
+
258
+ while(true){
259
+ const {done,value} = await reader.read();
260
+ if(done) break;
261
+ buf += decoder.decode(value, {stream:true});
262
+ processSSEBuffer();
263
+ }
264
+ // flush remaining
265
+ if(buf.trim()) processSSEBuffer();
266
+
267
+ function processSSEBuffer(){
268
+ const lines = buf.split('\n');
269
+ buf = lines.pop() || '';
270
+ for(const line of lines) handleSSELine(line);
271
+ }
272
+ } else {
273
+ // --- Fallback: read full text at once (proxy/polyfill environments) ---
274
+ const fullText = await resp.text();
275
+ for(const line of fullText.split('\n')) handleSSELine(line);
276
+ }
277
+
278
+ function handleSSELine(line){
279
+ const trimmed = line.trim();
280
+ if(!trimmed.startsWith('data: ')) return;
281
+ let parsed;
282
+ try{ parsed = JSON.parse(trimmed.slice(6)); } catch{ return; }
283
+ const {event, data} = parsed;
284
+ if(!event || !data) return;
285
+
286
+ // remove typing on first real event
287
+ if(!typingRemoved){ typingRemoved = true; typingEl.remove(); chatInner.appendChild(aRow); }
288
+
289
+ switch(event){
290
+ case 'text':
291
+ textAcc += data.text;
292
+ ensureTextEl();
293
+ textEl.innerHTML = renderMarkdown(textAcc);
294
+ scrollToBottom();
295
+ break;
296
+
297
+ case 'tool_use':{
298
+ textAcc = ''; textEl = null;
299
+ const icon = TOOL_ICONS[data.name] || '&#128295;';
300
+ const inputStr = typeof data.input === 'string' ? data.input : JSON.stringify(data.input);
301
+ const card = document.createElement('div');
302
+ card.className = 'tool-card';
303
+ card.id = 'tool-'+data.id;
304
+ card.innerHTML = `<div class="tool-hd"><span class="tool-icon">${icon}</span>${esc(data.name)}</div>`
305
+ + `<div class="tool-input">${esc(inputStr).slice(0,200)}</div>`;
306
+ aBubble.appendChild(card);
307
+ scrollToBottom();
308
+ break;
309
+ }
310
+
311
+ case 'tool_result':{
312
+ const card = document.getElementById('tool-'+data.tool_use_id);
313
+ const rd = document.createElement('div');
314
+ rd.className = 'tool-result';
315
+ rd.textContent = (data.content||'').slice(0,3000);
316
+ if(card) card.after(rd); else aBubble.appendChild(rd);
317
+ scrollToBottom();
318
+ break;
319
+ }
320
+
321
+ case 'thinking':{
322
+ textAcc = ''; textEl = null;
323
+ const tb = document.createElement('div');
324
+ tb.className = 'think-box';
325
+ tb.textContent = data.thinking.slice(0,1000);
326
+ aBubble.appendChild(tb);
327
+ scrollToBottom();
328
+ break;
329
+ }
330
+
331
+ case 'result':{
332
+ const b = document.createElement('div');
333
+ b.className = 'result-banner ok';
334
+ b.innerHTML = `&#9989; Done &middot; $${(data.cost||0).toFixed(2)} &middot; ${((data.duration_ms||0)/1000).toFixed(1)}s &middot; ${(data.input_tokens||0)+(data.output_tokens||0)} tokens`;
335
+ aBubble.appendChild(b);
336
+ scrollToBottom();
337
+ break;
338
+ }
339
+
340
+ case 'error':{
341
+ const b = document.createElement('div');
342
+ b.className = 'result-banner err';
343
+ b.innerHTML = `&#10060; Error: ${esc(data.message||'unknown')}`;
344
+ aBubble.appendChild(b);
345
+ scrollToBottom();
346
+ break;
347
+ }
348
+ }
349
+ }
350
+
351
+ }catch(err){
352
+ if(!typingRemoved){ typingEl.remove(); chatInner.appendChild(aRow); }
353
+ const b = document.createElement('div');
354
+ b.className = 'result-banner err';
355
+ b.textContent = 'Network error: '+err.message;
356
+ aBubble.appendChild(b);
357
+ }
358
+
359
+ streaming = false;
360
+ sendBtn.disabled = false;
361
+ promptEl.focus();
362
+ }
363
+ </script>
364
+ </body>
365
+ </html>