@hncode/hncode 0.3.6 → 0.4.1

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 (148) hide show
  1. package/package.json +5 -3
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
  147. /package/bin/{install.js → install.cjs} +0 -0
  148. /package/bin/{preinstall.js → preinstall.cjs} +0 -0
package/src/llm.js CHANGED
@@ -31,14 +31,65 @@ export function anthropicToolDefs(tools) {
31
31
  input_schema: t.parameters,
32
32
  }));
33
33
  }
34
+ // ---------------------------------------------------------------------------
35
+ // Multimodal content
36
+ //
37
+ // A tool may return `{ media: [...] }` instead of a string — ReadMediaFile does,
38
+ // so a screenshot reaches the model as an IMAGE rather than as a description of
39
+ // one. The entries are normalized here, once, into the shapes each protocol
40
+ // wants; everything upstream (agent, tools) stays protocol-agnostic.
41
+ //
42
+ // { type: 'image', mimeType: 'image/png', data: '<base64>' }
43
+ // { type: 'text', text: '...' }
44
+ //
45
+ // `data` is base64 WITHOUT the `data:` prefix; the prefix is added per protocol.
46
+
47
+ function isMediaContent(c) {
48
+ return !!c && typeof c === 'object' && Array.isArray(c.media);
49
+ }
50
+
51
+ function mediaText(c) {
52
+ return String(c.text == null ? '' : c.text);
53
+ }
54
+
55
+ /** OpenAI: content parts, `image_url` carries the data URL. */
56
+ function toOpenAiContent(c) {
57
+ const parts = [];
58
+ const text = mediaText(c);
59
+ if (text) parts.push({ type: 'text', text });
60
+ for (const m of c.media) {
61
+ if (m && m.type === 'image' && m.data) {
62
+ parts.push({ type: 'image_url', image_url: { url: `data:${m.mimeType || 'image/png'};base64,${m.data}` } });
63
+ }
64
+ }
65
+ // A media block with no image and no text would serialise as an empty array,
66
+ // which some providers reject. Fall back to a plain string.
67
+ if (!parts.length) return text || '';
68
+ return parts;
69
+ }
70
+
71
+ /** Anthropic: content blocks, `image` carries source bytes. */
72
+ function toAnthropicContent(c) {
73
+ const blocks = [];
74
+ const text = mediaText(c);
75
+ if (text) blocks.push({ type: 'text', text });
76
+ for (const m of c.media) {
77
+ if (m && m.type === 'image' && m.data) {
78
+ blocks.push({ type: 'image', source: { type: 'base64', media_type: m.mimeType || 'image/png', data: m.data } });
79
+ }
80
+ }
81
+ if (!blocks.length) return text || '';
82
+ return blocks;
83
+ }
34
84
 
35
85
  // Internal canonical messages -> OpenAI request shape.
36
86
  export function toOpenAi(messages) {
37
87
  const out = [];
38
88
  for (const m of messages) {
39
89
  if (m.role === 'system') out.push({ role: 'system', content: m.content });
40
- else if (m.role === 'user') out.push({ role: 'user', content: m.content });
41
- else if (m.role === 'assistant') {
90
+ else if (m.role === 'user') {
91
+ out.push({ role: 'user', content: isMediaContent(m.content) ? toOpenAiContent(m.content) : m.content });
92
+ } else if (m.role === 'assistant') {
42
93
  const hasText = typeof m.content === 'string' && m.content.trim();
43
94
  const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
44
95
  if (!hasText && !hasCalls) continue; // 上游不允许空 assistant,直接丢弃
@@ -52,7 +103,11 @@ export function toOpenAi(messages) {
52
103
  }
53
104
  out.push(msg);
54
105
  } else if (m.role === 'tool') {
55
- out.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content });
106
+ out.push({
107
+ role: 'tool',
108
+ tool_call_id: m.toolCallId,
109
+ content: isMediaContent(m.content) ? toOpenAiContent(m.content) : m.content,
110
+ });
56
111
  }
57
112
  }
58
113
  return out;
@@ -64,9 +119,20 @@ export function toAnthropic(messages) {
64
119
  const out = [];
65
120
  for (const m of messages) {
66
121
  if (m.role === 'system') { sys.push(m.content); continue; }
67
- if (m.role === 'user') { out.push({ role: 'user', content: [{ type: 'text', text: m.content }] }); continue; }
122
+ if (m.role === 'user') {
123
+ const c = isMediaContent(m.content) ? toAnthropicContent(m.content) : [{ type: 'text', text: m.content }];
124
+ out.push({ role: 'user', content: c });
125
+ continue;
126
+ }
68
127
  if (m.role === 'tool') {
69
- out.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: m.content }] });
128
+ out.push({
129
+ role: 'user',
130
+ content: [{
131
+ type: 'tool_result',
132
+ tool_use_id: m.toolCallId,
133
+ content: isMediaContent(m.content) ? toAnthropicContent(m.content) : m.content,
134
+ }],
135
+ });
70
136
  continue;
71
137
  }
72
138
  if (m.role === 'assistant') {
@@ -78,7 +144,7 @@ export function toAnthropic(messages) {
78
144
  out.push({ role: 'assistant', content: blocks });
79
145
  }
80
146
  }
81
- return { system: sys.join('\n\n').trim(), messages: out };
147
+ return { system: sys.join('\n\n').trim(), messages: out };
82
148
  }
83
149
 
84
150
  function authHeaders(cfg) {
@@ -94,17 +160,31 @@ function authHeaders(cfg) {
94
160
  return h;
95
161
  }
96
162
 
97
- function buildBody(cfg, messages, streaming = true) {
163
+ function buildBody(cfg, messages, streaming = true, opts = {}) {
98
164
  const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
99
165
  if (cfg.temperature != null) common.temperature = cfg.temperature;
100
166
  // Thinking / reasoning. The wire form depends on the protocol and the chosen
101
167
  // effort (see config.effortWire): OpenAI-compatible sends `reasoning_effort`,
102
168
  // Anthropic sends a `thinking` budget. Models that think by default need no
103
169
  // flag, so nothing is sent when the effort is off/unset.
104
- const wire = effortWire(cfg, cfg.effort) || {};
170
+ //
171
+ // `noReasoning` opts a request OUT of it. Internal one-shot requests that cap
172
+ // their output (the session title, the compaction summary) must not ask for
173
+ // thinking: a reasoning model spends the whole small budget on
174
+ // `reasoning_content`, returns `finish_reason: "length"` with empty `content`,
175
+ // and the request comes back as a failure that never even shows an error.
176
+ const wire = opts.noReasoning ? {} : (effortWire(cfg, cfg.effort) || {});
177
+ // Internal requests that have no use for tools must not carry the ~25 KB tool
178
+ // block: it is pure input cost on top of a request that only wants one line.
179
+ const defs = Object.prototype.hasOwnProperty.call(opts, 'tools') ? openAiToolDefs(opts.tools) : openAiToolDefs(llmToolsList);
105
180
  if (cfg.protocol === 'anthropic') {
106
181
  const { system, messages: am } = toAnthropic(messages);
107
- const body = { model: cfg.innerModel, system, messages: am, tools: anthropicToolDefs(llmToolsList), ...common, ...wire };
182
+ const body = {
183
+ model: cfg.innerModel, system, messages: am,
184
+ tools: opts.noTools ? undefined : anthropicToolDefs(opts.tools || llmToolsList),
185
+ ...common, ...wire,
186
+ };
187
+ if (!body.tools || !body.tools.length) delete body.tools;
108
188
  // Prompt caching: mark the stable prefix (tools + system + conversation head)
109
189
  // so the provider bills it as a cache READ instead of fresh input. The key is
110
190
  // the SESSION id, so it stays identical across turns and never thrashes.
@@ -113,7 +193,11 @@ function buildBody(cfg, messages, streaming = true) {
113
193
  sessionKey: cfg.sessionId,
114
194
  }));
115
195
  }
116
- const body = { model: cfg.innerModel, messages: toOpenAi(messages), tools: openAiToolDefs(llmToolsList), ...common, ...wire };
196
+ const body = {
197
+ model: cfg.innerModel, messages: toOpenAi(messages),
198
+ ...common, ...wire,
199
+ };
200
+ if (!opts.noTools) body.tools = defs;
117
201
  return JSON.stringify(applyPromptCache('openai', body, {
118
202
  enabled: cfg.promptCache !== false,
119
203
  sessionKey: cfg.sessionId,
@@ -153,14 +237,18 @@ export class LLM {
153
237
  // Simple non-streaming text request — used for internal tasks (e.g. context
154
238
  // summarization during compaction) that should not surface a tool plan to the
155
239
  // user. Returns the assistant's text content or null on failure.
156
- async requestText(messages) {
240
+ //
241
+ // `opts.noReasoning` / `opts.noTools` exist for the small one-shot requests:
242
+ // a capped budget plus a reasoning model is what silently produced an empty
243
+ // title (see buildBody), and an internal request has no tools to call.
244
+ async requestText(messages, opts = {}) {
157
245
  const cfg = this.cfg;
158
246
  this.controller = new AbortController();
159
247
  try {
160
248
  const res = await fetch(cfg.endpoint, {
161
249
  method: 'POST',
162
250
  headers: authHeaders(cfg),
163
- body: buildBody(cfg, messages, false),
251
+ body: buildBody(cfg, messages, false, opts),
164
252
  signal: this.controller.signal,
165
253
  });
166
254
  if (!res || !res.ok) {
@@ -179,7 +267,18 @@ export class LLM {
179
267
  const msg = json.choices && json.choices[0] && json.choices[0].message;
180
268
  if (msg && msg.content) text = msg.content;
181
269
  }
182
- return text || null;
270
+ // An EMPTY reply that hit the output cap is a failure, not an answer. The
271
+ // title request used to receive `content: ""` + `finish_reason: "length"`
272
+ // and treat it as "the model had nothing to say", which is why the title
273
+ // was silently never generated (and never retried). Returning it marked
274
+ // lets the caller decide — the title path retries with a bigger budget.
275
+ if (!text) {
276
+ const truncated = cfg.protocol === 'anthropic'
277
+ ? (json.stop_reason === 'max_tokens')
278
+ : !!(json.choices && json.choices[0] && json.choices[0].finish_reason === 'length');
279
+ return truncated ? '' : null;
280
+ }
281
+ return text;
183
282
  } catch (e) {
184
283
  if (e && e.name === 'AbortError') return null;
185
284
  return null;
@@ -394,9 +493,16 @@ function processEvent(protocol, raw, onEvent, state) {
394
493
  }
395
494
 
396
495
  function processOpenAI(d, onEvent, state) {
397
- const delta = d.choices && d.choices[0] && d.choices[0].delta;
496
+ const choice = d.choices && d.choices[0];
497
+ const delta = choice && choice.delta;
498
+ const finishReason = choice && choice.finish_reason;
499
+ // Some servers put the final chunk's content AND its finish_reason in the SAME
500
+ // frame, so the stop reason has to be handled AFTER the delta — checking it
501
+ // only on a delta-less frame missed the truncation marker entirely (a stream
502
+ // that reported `length` alongside the last token looked like a normal end).
398
503
  if (!delta) {
399
- if (d.choices && d.choices[0] && d.choices[0].finish_reason) {
504
+ if (finishReason) {
505
+ if (finishReason === 'length') onEvent({ type: 'truncated' });
400
506
  onEvent({ type: 'end' });
401
507
  return 'end';
402
508
  }
@@ -440,6 +546,14 @@ function processOpenAI(d, onEvent, state) {
440
546
  }
441
547
  }
442
548
  }
549
+ // A frame can carry BOTH the last delta and the stop reason (see above). Now
550
+ // that the delta is consumed, report the cut and end the round — mirroring the
551
+ // `message_stop` path for Anthropic.
552
+ if (finishReason) {
553
+ if (finishReason === 'length') onEvent({ type: 'truncated' });
554
+ onEvent({ type: 'end' });
555
+ return 'end';
556
+ }
443
557
  return 'continue';
444
558
  }
445
559
 
@@ -447,6 +561,11 @@ function processAnthropic(d, onEvent, state) {
447
561
  const t = d.type;
448
562
  if (t === 'message_start' || t === 'message_delta' || t === 'message_stop') {
449
563
  if (t === 'message_stop') { onEvent({ type: 'end' }); return 'end'; }
564
+ // `message_delta` carries the stop reason; `max_tokens` there means the reply
565
+ // was cut off, not finished (see processOpenAI for the OpenAI spelling).
566
+ if (t === 'message_delta' && d.delta && d.delta.stop_reason === 'max_tokens') {
567
+ onEvent({ type: 'truncated' });
568
+ }
450
569
  return 'continue';
451
570
  }
452
571
  if (t === 'content_block_start') {
@@ -481,6 +600,7 @@ export function consumeNonStreaming(protocol, json, onEvent) {
481
600
  const state = { tagPending: '', inThink: false };
482
601
  if (protocol === 'anthropic') {
483
602
  const content = json.content || [];
603
+ if (json.stop_reason === 'max_tokens') onEvent({ type: 'truncated' });
484
604
  for (const cb of content) {
485
605
  if (cb.type === 'text' && cb.text) feedContent(cb.text, onEvent, state);
486
606
  else if (cb.type === 'tool_use') {
@@ -490,8 +610,11 @@ export function consumeNonStreaming(protocol, json, onEvent) {
490
610
  }
491
611
  }
492
612
  } else {
493
- const msg = json.choices && json.choices[0] && json.choices[0].message;
613
+ const choice = json.choices && json.choices[0];
614
+ const msg = choice && choice.message;
494
615
  if (!msg) { onEvent({ type: 'error', error: new Error('empty LLM response') }); return; }
616
+ // Non-streaming: the truncation marker comes on the same object as the message.
617
+ if (choice.finish_reason === 'length') onEvent({ type: 'truncated' });
495
618
  if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) onEvent({ type: 'think', text: msg.reasoning_content });
496
619
  else if (typeof msg.reasoning === 'string' && msg.reasoning) onEvent({ type: 'think', text: msg.reasoning });
497
620
  else if (Array.isArray(msg.reasoning_details)) {
@@ -0,0 +1,120 @@
1
+ // Permission rules — standing allow / ask / deny decisions, mirroring Claude Code's
2
+ // `permissions` block in settings.
3
+ //
4
+ // Why this exists: without it, "always allow npm test" has no home. The user answers
5
+ // the same approval prompt on every turn, or switches the whole session to Auto
6
+ // and loses the prompt for genuinely dangerous commands too. A rule set lets the
7
+ // routine work through while the rest keeps asking.
8
+ //
9
+ // Rule syntax (a subset of Claude Code's, chosen to stay readable):
10
+ // Bash — every Bash call
11
+ // Bash(npm run test) — that exact command
12
+ // Bash(npm run test *) — a command starting with `npm run test `
13
+ // Bash(git push *) — same idea, for a command with arguments
14
+ // Read(./src/**) — a tool whose `path` / `file_path` argument matches the glob
15
+ // Write(./dist/**) — same, for the path-taking tools
16
+ // Edit — the tool, whatever its arguments
17
+ // An empty argument list means "any call of this tool".
18
+ //
19
+ // Precedence is deny > ask > allow, and it is checked before every other mode rule:
20
+ // a deny must not be reachable around by switching to YOLO, and an explicit ask must
21
+ // still prompt inside a mode that would otherwise auto-approve.
22
+
23
+ // Which argument names hold a path, per tool. Mirrors the tools' own schemas.
24
+ const PATH_ARGS = {
25
+ Read: ['path', 'file_path'],
26
+ Write: ['path', 'file_path'],
27
+ Edit: ['path', 'file_path'],
28
+ FileLines: ['path', 'file_path'],
29
+ Glob: ['path', 'pattern'],
30
+ Grep: ['path', 'pattern'],
31
+ };
32
+
33
+ // `Bash(npm run test)` / `Read(./src/**)` -> { tool, arg }
34
+ export function parseRule(rule) {
35
+ const text = String(rule == null ? '' : rule).trim();
36
+ if (!text) return null;
37
+ const open = text.indexOf('(');
38
+ if (open === -1) return { tool: text, arg: null };
39
+ if (!text.endsWith(')')) return null; // malformed: a `(` with no `)`
40
+ const tool = text.slice(0, open).trim();
41
+ if (!tool) return null;
42
+ return { tool, arg: text.slice(open + 1, -1).trim() };
43
+ }
44
+
45
+ // Glob -> RegExp. Supports `**` (any depth), `*` (within a segment) and `?`.
46
+ // Deliberately small: enough for path and command-prefix matching, with no
47
+ // dependency and no surprise backtracking.
48
+ function globToRegExp(pattern) {
49
+ let out = '';
50
+ for (let i = 0; i < pattern.length; i++) {
51
+ const c = pattern[i];
52
+ if (c === '*') {
53
+ if (pattern[i + 1] === '*') { out += '.*'; i++; if (pattern[i + 1] === '/') i++; }
54
+ else out += '[^/]*';
55
+ } else if (c === '?') out += '[^/]';
56
+ else out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+ return new RegExp('^' + out + '$');
59
+ }
60
+
61
+ // Does `rule` (already parsed) match this call? `args` is the tool's argument object.
62
+ export function ruleMatches(parsed, toolName, args) {
63
+ if (!parsed || parsed.tool !== toolName) return false;
64
+ if (parsed.arg === null || parsed.arg === '') return true; // bare tool name: any call
65
+
66
+ const a = args || {};
67
+ // Bash: the rule names a command. A trailing `*` means "starts with"; otherwise the
68
+ // command must match exactly, so `Bash(rm -rf /)` cannot be widened by an argument.
69
+ if (toolName === 'Bash') {
70
+ const cmd = String(a.command || a.cmd || '').trim();
71
+ if (parsed.arg.endsWith('*')) {
72
+ const prefix = parsed.arg.slice(0, -1).trim();
73
+ return cmd === prefix || cmd.startsWith(prefix + ' ');
74
+ }
75
+ return cmd === parsed.arg;
76
+ }
77
+
78
+ // Path-taking tools: match any of the tool's path-ish arguments against the glob,
79
+ // so `Read(./src/**)` works whether the model sent `path` or `file_path`.
80
+ const names = PATH_ARGS[toolName] || ['path', 'file_path', 'pattern'];
81
+ for (const n of names) {
82
+ const v = a[n];
83
+ if (typeof v !== 'string' || !v) continue;
84
+ // Accept both `./src/**` and `src/**` for the same intent.
85
+ const norm = v.replace(/\\/g, '/').replace(/^\.\//, '');
86
+ const pat = parsed.arg.replace(/\\/g, '/').replace(/^\.\//, '');
87
+ if (globToRegExp(pat).test(norm) || globToRegExp(pat).test(v.replace(/\\/g, '/'))) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ // The decision for one call: 'deny' | 'ask' | 'allow' | null (no rule matched).
93
+ export function decideFromRules(rules, toolName, args) {
94
+ const list = rules || {};
95
+ const test = (key) => {
96
+ const arr = Array.isArray(list[key]) ? list[key] : [];
97
+ for (const raw of arr) {
98
+ if (ruleMatches(parseRule(raw), toolName, args)) return true;
99
+ }
100
+ return false;
101
+ };
102
+ // deny wins outright; then an explicit ask; then allow.
103
+ if (test('deny')) return 'deny';
104
+ if (test('ask')) return 'ask';
105
+ if (test('allow')) return 'allow';
106
+ return null;
107
+ }
108
+
109
+ // Human-readable summary for /permissions, so a user can see what is in force.
110
+ export function describeRules(rules) {
111
+ const list = rules || {};
112
+ const lines = [];
113
+ for (const key of ['allow', 'ask', 'deny']) {
114
+ const arr = Array.isArray(list[key]) ? list[key] : [];
115
+ lines.push(`${key} (${arr.length}):`);
116
+ if (!arr.length) lines.push(' (none)');
117
+ else for (const r of arr) lines.push(' ' + r);
118
+ }
119
+ return lines.join('\n');
120
+ }