@yeaft/webchat-agent 1.0.206 → 1.0.207

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.
@@ -1 +1 @@
1
- {"version":"1.0.206"}
1
+ {"version":"1.0.207"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.206",
3
+ "version": "1.0.207",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/cli.js CHANGED
@@ -24,6 +24,9 @@
24
24
  */
25
25
 
26
26
  import { createInterface } from 'readline';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
27
30
  import { join } from 'path';
28
31
  import { loadConfig } from './config.js';
29
32
  import { DebugTrace } from './debug-trace.js';
@@ -33,10 +36,13 @@ import { buildSystemPrompt } from './prompts.js';
33
36
  import { searchMessages } from './conversation/search.js';
34
37
  import { ConversationStore } from './conversation/persist.js';
35
38
  import { snapshotSessions } from './sessions/session-crud.js';
39
+ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
40
+ import { validateSessionId } from './sessions/ids.js';
41
+ import { createJsonlWriter, JsonlInput, runStreamTurn } from './stdio-protocol.js';
36
42
 
37
43
  // ─── Argument parsing ──────────────────────────────────────────
38
44
 
39
- function parseArgs(argv) {
45
+ export function parseArgs(argv) {
40
46
  const args = {
41
47
  debug: false,
42
48
  interactive: false,
@@ -51,6 +57,12 @@ function parseArgs(argv) {
51
57
  compactOrphans: false,
52
58
  compactOrphansDry: false,
53
59
  deleteSession: null,
60
+ sessionId: null,
61
+ workDir: null,
62
+ modelEffort: null,
63
+ inputFormat: 'text',
64
+ outputFormat: 'text',
65
+ print: false,
54
66
  prompt: null,
55
67
  };
56
68
 
@@ -75,6 +87,28 @@ function parseArgs(argv) {
75
87
  case '--model':
76
88
  args.model = rest[++i] || null;
77
89
  break;
90
+ case '--effort':
91
+ case '--model-effort':
92
+ args.modelEffort = rest[++i] || null;
93
+ break;
94
+ case '--session-id':
95
+ case '--resume':
96
+ args.sessionId = rest[++i] || null;
97
+ break;
98
+ case '--cwd':
99
+ case '--work-dir':
100
+ args.workDir = rest[++i] || null;
101
+ break;
102
+ case '--input-format':
103
+ args.inputFormat = rest[++i] || 'text';
104
+ break;
105
+ case '--output-format':
106
+ args.outputFormat = rest[++i] || 'text';
107
+ break;
108
+ case '-p':
109
+ case '--print':
110
+ args.print = true;
111
+ break;
78
112
  case '--language':
79
113
  args.language = rest[++i] || null;
80
114
  break;
@@ -693,6 +727,107 @@ async function runREPL(config, args) {
693
727
  });
694
728
  }
695
729
 
730
+ // ─── Structured stdio handler ──────────────────────────────────
731
+
732
+ async function runStreamJson(config, args) {
733
+ const sessionId = args.sessionId || `session_cli_${randomUUID()}`;
734
+ const validation = validateSessionId(sessionId);
735
+ if (!validation.ok) {
736
+ throw new Error(`Invalid --session-id (${validation.reason})`);
737
+ }
738
+ const workDir = resolve(args.workDir || process.cwd());
739
+ const persisted = args.sessionId ? loadSessionConfig(config.dir, sessionId) : {};
740
+ const effectiveConfig = resolveSessionConfig(config, persisted);
741
+ if (args.model) {
742
+ effectiveConfig.model = args.model;
743
+ effectiveConfig.primaryModel = args.model;
744
+ }
745
+ if (args.modelEffort) effectiveConfig.modelEffort = args.modelEffort;
746
+
747
+ const write = createJsonlWriter(process.stdout);
748
+ const input = args.inputFormat === 'stream-json' ? new JsonlInput(process.stdin) : null;
749
+ const originalConsole = { log: console.log, info: console.info, debug: console.debug };
750
+ const writeDiagnostic = (...values) => console.error(...values);
751
+ console.log = writeDiagnostic;
752
+ console.info = writeDiagnostic;
753
+ console.debug = writeDiagnostic;
754
+
755
+ const loaded = await loadSession({
756
+ dir: config.dir,
757
+ workDir,
758
+ model: effectiveConfig.model,
759
+ language: args.language || effectiveConfig.language,
760
+ debug: args.debug || effectiveConfig.debug,
761
+ skipMCP: args.skipMCP,
762
+ skipSkills: args.skipSkills,
763
+ configOverrides: {
764
+ ...effectiveConfig,
765
+ ...(effectiveConfig.modelEffort ? { modelEffort: effectiveConfig.modelEffort } : {}),
766
+ },
767
+ });
768
+ const { engine, conversationStore, skillManager, toolRegistry } = loaded;
769
+ const todoState = { value: [] };
770
+
771
+ write({
772
+ type: 'system',
773
+ subtype: 'init',
774
+ session_id: sessionId,
775
+ model: loaded.config.model,
776
+ model_effort: loaded.config.modelEffort || null,
777
+ cwd: workDir,
778
+ tools: toolRegistry.names,
779
+ skills: skillManager.list(),
780
+ input_format: args.inputFormat,
781
+ output_format: 'stream-json',
782
+ });
783
+
784
+ const runPrompt = async (prompt) => {
785
+ const priorMessages = conversationStore.loadRecentBySession(sessionId, 20).map(message => ({
786
+ role: message.role,
787
+ content: message.content,
788
+ ...(message.toolCallId && { toolCallId: message.toolCallId }),
789
+ ...(message.toolCalls && { toolCalls: message.toolCalls }),
790
+ }));
791
+ return runStreamTurn({
792
+ engine,
793
+ prompt,
794
+ messages: priorMessages,
795
+ sessionId,
796
+ workDir,
797
+ model: loaded.config.model,
798
+ modelEffort: loaded.config.modelEffort || null,
799
+ input,
800
+ write,
801
+ getCurrentTodos: () => todoState.value.slice(),
802
+ setCurrentTodos: todos => { todoState.value = Array.isArray(todos) ? todos.slice() : []; },
803
+ });
804
+ };
805
+
806
+ let lastResult = null;
807
+ try {
808
+ if (args.prompt) {
809
+ lastResult = await runPrompt(args.prompt);
810
+ } else if (input) {
811
+ for (;;) {
812
+ const item = await input.nextPrompt();
813
+ if (!item) break;
814
+ lastResult = await runPrompt(item.prompt);
815
+ }
816
+ } else {
817
+ let prompt = '';
818
+ for await (const chunk of process.stdin) prompt += chunk;
819
+ if (prompt.trim()) lastResult = await runPrompt(prompt.trim());
820
+ }
821
+ } finally {
822
+ input?.close();
823
+ await loaded.shutdown();
824
+ console.log = originalConsole.log;
825
+ console.info = originalConsole.info;
826
+ console.debug = originalConsole.debug;
827
+ }
828
+ if (lastResult?.is_error) process.exitCode = 1;
829
+ }
830
+
696
831
  // ─── One-shot handler ──────────────────────────────────────────
697
832
 
698
833
  async function runOnce(config, args) {
@@ -819,6 +954,20 @@ async function main() {
819
954
  return;
820
955
  }
821
956
 
957
+ if (args.outputFormat === 'stream-json') {
958
+ if (args.inputFormat !== 'text' && args.inputFormat !== 'stream-json') {
959
+ throw new Error('--input-format must be text or stream-json');
960
+ }
961
+ if (!args.prompt && args.inputFormat !== 'stream-json' && process.stdin.isTTY) {
962
+ throw new Error('stream-json output requires a prompt, piped stdin, or --input-format stream-json');
963
+ }
964
+ await runStreamJson(config, args);
965
+ return;
966
+ }
967
+ if (args.inputFormat === 'stream-json') {
968
+ throw new Error('--input-format stream-json requires --output-format stream-json');
969
+ }
970
+
822
971
  // Handle prompt (from args or stdin)
823
972
  if (args.prompt) {
824
973
  await runOnce(config, args);
@@ -856,15 +1005,24 @@ async function main() {
856
1005
  console.log(' -d, --debug Enable debug tracing');
857
1006
  console.log(' -i, --interactive Start REPL');
858
1007
  console.log(' -v, --verbose Verbose output');
859
- console.log(' --model <name> Override model');
860
- console.log(' --language <code> Language: en, zh (default: en)');
1008
+ console.log(' -p, --print Run a non-interactive query');
1009
+ console.log(' --session-id <id> Persist and resume a Yeaft Session');
1010
+ console.log(' --cwd <dir> Set the working directory');
1011
+ console.log(' --model <name> Override model');
1012
+ console.log(' --effort <level> Override model effort');
1013
+ console.log(' --input-format <fmt> text or stream-json');
1014
+ console.log(' --output-format <fmt> text or stream-json');
1015
+ console.log(' --language <code> Language: en, zh (default: en)');
861
1016
  console.log(' --trace <cmd> Query debug trace');
862
1017
  console.log(' --dry-run Show prompt without calling LLM');
863
1018
  console.log(' --skip-mcp Skip MCP server connections');
864
1019
  console.log(' --skip-skills Skip skill loading');
865
1020
  }
866
1021
 
867
- main().catch(err => {
868
- console.error(err);
869
- process.exit(1);
870
- });
1022
+ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1023
+ if (isDirectRun) {
1024
+ main().catch(err => {
1025
+ console.error(err);
1026
+ process.exit(1);
1027
+ });
1028
+ }
package/yeaft/engine.js CHANGED
@@ -1086,12 +1086,12 @@ export class Engine {
1086
1086
  * @param {string} [args.explicitSkillName] — leading /skill:<name> command, if present
1087
1087
  * @returns {string}
1088
1088
  */
1089
- #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, workCenterInstructions, projectDoc, taskCtx, activeTasks, explicitSkillName } = {}) {
1090
- // Get relevant skill content if SkillManager is wired. A leading
1091
- // /skill:<name> is explicit, not relevance matching: load that skill by
1092
- // name or inject a visible prompt warning when the command is unknown.
1093
- let skillContent = '';
1094
- if (this.#skillManager) {
1089
+ #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, workCenterInstructions, projectDoc, taskCtx, activeTasks, explicitSkillName, resolvedSkillContent = null } = {}) {
1090
+ // Skill selection is normally resolved once by #runQuery so the prompt and
1091
+ // emitted protocol events describe the exact same skills. Keep the local
1092
+ // fallback for internal callers that do not need selection events.
1093
+ let skillContent = typeof resolvedSkillContent === 'string' ? resolvedSkillContent : '';
1094
+ if (resolvedSkillContent === null && this.#skillManager) {
1095
1095
  if (explicitSkillName) {
1096
1096
  skillContent = this.#skillManager.getPromptContent(explicitSkillName)
1097
1097
  || `## Skill command error\n\nRequested skill "${explicitSkillName}" was not found. Continue without that skill and tell the user it is unavailable.`;
@@ -2004,6 +2004,32 @@ export class Engine {
2004
2004
  const activeTasks = this.#taskManager
2005
2005
  ? this.#taskManager.renderActiveTasksForPrompt(runtimeSessionId)
2006
2006
  : '';
2007
+ let resolvedSkillContent = '';
2008
+ let resolvedSkills = [];
2009
+ let skillResolutionError = null;
2010
+ if (this.#skillManager) {
2011
+ if (explicitSkillName) {
2012
+ resolvedSkillContent = this.#skillManager.getPromptContent(explicitSkillName);
2013
+ const skill = this.#skillManager.list?.().find(item => item.name === explicitSkillName)
2014
+ || (resolvedSkillContent ? { name: explicitSkillName } : null);
2015
+ if (resolvedSkillContent && skill) {
2016
+ resolvedSkills = [{ ...skill, explicit: true }];
2017
+ } else {
2018
+ skillResolutionError = `Requested skill "${explicitSkillName}" was not found.`;
2019
+ resolvedSkillContent = `## Skill command error\n\n${skillResolutionError} Continue without that skill and tell the user it is unavailable.`;
2020
+ }
2021
+ } else if (prompt && typeof this.#skillManager.findRelevant === 'function') {
2022
+ resolvedSkills = this.#skillManager.findRelevant(prompt).map(skill => ({
2023
+ name: skill.name,
2024
+ description: skill.description || '',
2025
+ trigger: skill.trigger || '',
2026
+ category: skill.category,
2027
+ tier: skill._tier,
2028
+ explicit: false,
2029
+ }));
2030
+ resolvedSkillContent = resolvedSkills.map(skill => this.#skillManager.getPromptContent(skill.name)).join('\n\n');
2031
+ }
2032
+ }
2007
2033
 
2008
2034
  const systemPrompt = this.#buildSystemPrompt({
2009
2035
  prompt,
@@ -2015,6 +2041,7 @@ export class Engine {
2015
2041
  projectDoc,
2016
2042
  activeTasks,
2017
2043
  explicitSkillName,
2044
+ resolvedSkillContent,
2018
2045
  });
2019
2046
 
2020
2047
  // ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
@@ -2184,6 +2211,12 @@ export class Engine {
2184
2211
  sessionId: sessionId || null,
2185
2212
  at: queryStartedAt,
2186
2213
  };
2214
+ for (const skill of resolvedSkills) {
2215
+ yield { type: 'skill_loaded', turnId: queryTurnId, skill };
2216
+ }
2217
+ if (skillResolutionError) {
2218
+ yield { type: 'skill_error', turnId: queryTurnId, skillName: explicitSkillName, message: skillResolutionError };
2219
+ }
2187
2220
 
2188
2221
  // Surface memory recall to the debug panel right after turn_open.
2189
2222
  // recallResult was loaded above; emit a structured `memory_used`
@@ -49,6 +49,17 @@ export function nextSessionId(slug = 'default') {
49
49
  return `session_${safe}_${randEncoded(8)}`;
50
50
  }
51
51
 
52
+ const SESSION_ID_RE = /^session_[A-Za-z0-9][A-Za-z0-9._-]*$/;
53
+ const SESSION_ID_MAX_LEN = 128;
54
+
55
+ /** Validate an external Session ID before it reaches any filesystem-backed subsystem. */
56
+ export function validateSessionId(id) {
57
+ if (!id || typeof id !== 'string') return { ok: false, reason: 'empty_or_non_string' };
58
+ if (id.length > SESSION_ID_MAX_LEN) return { ok: false, reason: 'too_long' };
59
+ if (!SESSION_ID_RE.test(id)) return { ok: false, reason: 'invalid_shape' };
60
+ return { ok: true };
61
+ }
62
+
52
63
  /**
53
64
  * Reserved vpIds that must never be used as actual VP identifiers — they
54
65
  * collide with coordinator-level sentinels (`@all` broadcast, `user`/`system`
@@ -0,0 +1,299 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ const TERMINAL_STOP_REASONS = new Set(['end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error']);
5
+
6
+ function snakeUsage(usage) {
7
+ const inputTokens = usage.inputTokens || 0;
8
+ const outputTokens = usage.outputTokens || 0;
9
+ const cacheReadTokens = usage.cacheReadTokens || 0;
10
+ const cacheWriteTokens = usage.cacheWriteTokens || 0;
11
+ const cacheDelta = usage.cacheTokensAreIncludedInInput ? 0 : cacheReadTokens + cacheWriteTokens;
12
+ return {
13
+ input_tokens: inputTokens,
14
+ output_tokens: outputTokens,
15
+ cache_read_input_tokens: cacheReadTokens,
16
+ cache_creation_input_tokens: cacheWriteTokens,
17
+ total_input_tokens: inputTokens + cacheDelta,
18
+ total_tokens: inputTokens + cacheDelta + outputTokens,
19
+ };
20
+ }
21
+
22
+ export function extractPrompt(message) {
23
+ if (!message || typeof message !== 'object') return '';
24
+ if (typeof message.prompt === 'string') return message.prompt;
25
+ if (typeof message.text === 'string') return message.text;
26
+ const content = message.message?.content ?? message.content;
27
+ if (typeof content === 'string') return content;
28
+ if (!Array.isArray(content)) return '';
29
+ return content
30
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
31
+ .map(block => block.text)
32
+ .join('');
33
+ }
34
+
35
+ export function createJsonlWriter(output = process.stdout) {
36
+ return (event) => {
37
+ output.write(`${JSON.stringify(event)}\n`);
38
+ };
39
+ }
40
+
41
+ export class JsonlInput {
42
+ #rl;
43
+ #prompts = [];
44
+ #promptWaiters = [];
45
+ #answers = new Map();
46
+ #answerWaiters = new Map();
47
+ #answerRequestIds = new Set();
48
+ #closed = false;
49
+ #error = null;
50
+
51
+ constructor(input = process.stdin) {
52
+ this.#rl = createInterface({ input, crlfDelay: Infinity, terminal: false });
53
+ this.#rl.on('line', line => this.#acceptLine(line));
54
+ this.#rl.on('close', () => this.#close());
55
+ }
56
+
57
+ #acceptLine(line) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed) return;
60
+ let message;
61
+ try {
62
+ message = JSON.parse(trimmed);
63
+ } catch (err) {
64
+ this.#error = new Error(`Invalid stream-json input: ${err.message}`);
65
+ this.#close();
66
+ return;
67
+ }
68
+ const type = message.type || '';
69
+ if (type === 'ask_user_response' || type === 'user_response') {
70
+ const requestId = message.request_id || message.requestId;
71
+ if (!requestId) return;
72
+ const value = message.answers ?? message.answer ?? message.response ?? {};
73
+ const waiter = this.#answerWaiters.get(requestId);
74
+ if (waiter) {
75
+ this.#answerWaiters.delete(requestId);
76
+ waiter.resolve(value);
77
+ } else if (this.#answerRequestIds.has(requestId)) {
78
+ this.#error = new Error(`Duplicate AskUser response request_id: ${requestId}`);
79
+ this.#close();
80
+ } else {
81
+ this.#answerRequestIds.add(requestId);
82
+ this.#answers.set(requestId, value);
83
+ }
84
+ return;
85
+ }
86
+ if (type === 'user' || type === 'prompt' || message.prompt !== undefined) {
87
+ const prompt = extractPrompt(message);
88
+ if (!prompt) return;
89
+ const item = { prompt, message };
90
+ const waiter = this.#promptWaiters.shift();
91
+ if (waiter) waiter.resolve(item);
92
+ else this.#prompts.push(item);
93
+ }
94
+ }
95
+
96
+ #close() {
97
+ if (this.#closed) return;
98
+ this.#closed = true;
99
+ for (const waiter of this.#promptWaiters.splice(0)) {
100
+ if (this.#error) waiter.reject(this.#error);
101
+ else waiter.resolve(null);
102
+ }
103
+ for (const waiter of this.#answerWaiters.values()) {
104
+ waiter.reject(this.#error || new Error('stdin closed before AskUser response'));
105
+ }
106
+ this.#answerWaiters.clear();
107
+ }
108
+
109
+ async nextPrompt() {
110
+ if (this.#prompts.length) return this.#prompts.shift();
111
+ if (this.#error) throw this.#error;
112
+ if (this.#closed) return null;
113
+ return new Promise((resolve, reject) => this.#promptWaiters.push({ resolve, reject }));
114
+ }
115
+
116
+ async waitForAnswer(requestId) {
117
+ if (!requestId || typeof requestId !== 'string') {
118
+ throw new Error('AskUser request_id must be a non-empty string');
119
+ }
120
+ if (this.#error) throw this.#error;
121
+ if (this.#answers.has(requestId)) {
122
+ const value = this.#answers.get(requestId);
123
+ this.#answers.delete(requestId);
124
+ return value;
125
+ }
126
+ if (this.#answerRequestIds.has(requestId)) {
127
+ throw new Error(`Duplicate AskUser request_id: ${requestId}`);
128
+ }
129
+ if (this.#error) throw this.#error;
130
+ if (this.#closed) throw new Error('stdin closed before AskUser response');
131
+ this.#answerRequestIds.add(requestId);
132
+ return new Promise((resolve, reject) => this.#answerWaiters.set(requestId, { resolve, reject }));
133
+ }
134
+
135
+ close() {
136
+ this.#rl.close();
137
+ }
138
+ }
139
+
140
+ export async function runStreamTurn({
141
+ engine,
142
+ prompt,
143
+ messages = [],
144
+ sessionId = null,
145
+ workDir = process.cwd(),
146
+ model = null,
147
+ modelEffort = null,
148
+ input = null,
149
+ write,
150
+ getCurrentTodos = null,
151
+ setCurrentTodos = null,
152
+ }) {
153
+ const clientTurnId = randomUUID();
154
+ let engineTurnId = null;
155
+ let resultText = '';
156
+ let stopReason = 'end_turn';
157
+ let failed = null;
158
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 };
159
+
160
+ const askUser = async ({ question, options }) => {
161
+ const requestId = randomUUID();
162
+ write({
163
+ type: 'ask_user',
164
+ subtype: 'request',
165
+ request_id: requestId,
166
+ session_id: sessionId,
167
+ turn_id: engineTurnId || clientTurnId,
168
+ question,
169
+ options: Array.isArray(options) ? options : [],
170
+ });
171
+ if (!input) throw new Error('AskUser requires --input-format stream-json');
172
+ const answers = await input.waitForAnswer(requestId);
173
+ write({
174
+ type: 'ask_user',
175
+ subtype: 'response',
176
+ request_id: requestId,
177
+ session_id: sessionId,
178
+ turn_id: engineTurnId || clientTurnId,
179
+ answers,
180
+ });
181
+ return answers;
182
+ };
183
+
184
+ try {
185
+ for await (const event of engine.query({
186
+ prompt,
187
+ messages,
188
+ sessionId,
189
+ workDir,
190
+ userEffort: modelEffort,
191
+ askUser,
192
+ getCurrentTodos,
193
+ setCurrentTodos,
194
+ })) {
195
+ if (event.type === 'turn_open') engineTurnId = event.turnId;
196
+ const turnId = engineTurnId || clientTurnId;
197
+ switch (event.type) {
198
+ case 'turn_open':
199
+ write({ ...event, type: 'turn', subtype: 'start', session_id: sessionId, turn_id: event.turnId, model, model_effort: modelEffort, cwd: workDir });
200
+ break;
201
+ case 'text_delta':
202
+ resultText += event.text || '';
203
+ write({ type: 'assistant', subtype: 'text_delta', session_id: sessionId, turn_id: turnId, delta: { type: 'text_delta', text: event.text || '' } });
204
+ break;
205
+ case 'thinking_delta':
206
+ write({ type: 'assistant', subtype: 'thinking_delta', session_id: sessionId, turn_id: turnId, delta: { type: 'thinking_delta', thinking: event.text || '' } });
207
+ break;
208
+ case 'skill_loaded':
209
+ write({ type: 'skill', subtype: 'loaded', session_id: sessionId, turn_id: turnId, skill: event.skill });
210
+ break;
211
+ case 'skill_error':
212
+ write({ type: 'skill', subtype: 'error', session_id: sessionId, turn_id: turnId, skill_name: event.skillName, error: event.message });
213
+ break;
214
+ case 'tool_call':
215
+ write({ type: 'assistant', subtype: 'tool_use', session_id: sessionId, turn_id: turnId, content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }] });
216
+ if (event.name === 'TodoWrite') {
217
+ write({ type: 'todo', subtype: 'update', session_id: sessionId, turn_id: turnId, tool_use_id: event.id, todos: event.input?.todos || [] });
218
+ }
219
+ break;
220
+ case 'tool_start':
221
+ write({ type: 'tool', subtype: 'start', session_id: sessionId, turn_id: turnId, tool_use_id: event.id, name: event.name, input: event.input });
222
+ break;
223
+ case 'tool_end':
224
+ write({
225
+ type: 'tool',
226
+ subtype: 'result',
227
+ session_id: sessionId,
228
+ turn_id: turnId,
229
+ tool_use_id: event.id,
230
+ name: event.name,
231
+ content: event.output,
232
+ is_error: !!event.isError,
233
+ ...(Array.isArray(event.displayImages) && event.displayImages.length > 0
234
+ ? { display_images: event.displayImages }
235
+ : {}),
236
+ });
237
+ break;
238
+ case 'usage': {
239
+ const cacheDelta = event.cacheTokensAreIncludedInInput ? 0 : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0);
240
+ usage.inputTokens += event.inputTokens || 0;
241
+ usage.outputTokens += event.outputTokens || 0;
242
+ usage.cacheReadTokens += event.cacheReadTokens || 0;
243
+ usage.cacheWriteTokens += event.cacheWriteTokens || 0;
244
+ usage.cacheInputDeltaTokens += cacheDelta;
245
+ write({ type: 'usage', session_id: sessionId, turn_id: turnId, usage: snakeUsage(event) });
246
+ break;
247
+ }
248
+ case 'stop':
249
+ if (event.stopReason) stopReason = event.stopReason;
250
+ break;
251
+ case 'turn_end':
252
+ if (event.stopReason && TERMINAL_STOP_REASONS.has(event.stopReason)) stopReason = event.stopReason;
253
+ break;
254
+ case 'turn_close':
255
+ write({ type: 'turn', subtype: 'stop', session_id: sessionId, turn_id: turnId, duration_ms: event.totalMs, loop_count: event.loopCount, total_tokens: event.totalTokens });
256
+ break;
257
+ case 'error':
258
+ failed = event.error instanceof Error ? event.error : new Error(event.error?.message || String(event.error || 'Unknown error'));
259
+ write({ type: 'error', session_id: sessionId, turn_id: turnId, error: { name: failed.name, message: failed.message }, retryable: !!event.retryable });
260
+ break;
261
+ case 'fallback':
262
+ case 'llm_retry':
263
+ case 'memory_used':
264
+ case 'recall':
265
+ case 'consolidate':
266
+ case 'reflection':
267
+ write({ ...event, session_id: sessionId, turn_id: turnId });
268
+ break;
269
+ }
270
+ }
271
+ } catch (err) {
272
+ failed = err;
273
+ write({ type: 'error', session_id: sessionId, turn_id: engineTurnId || clientTurnId, error: { name: err.name || 'Error', message: err.message || String(err) } });
274
+ }
275
+
276
+ const finalUsage = {
277
+ input_tokens: usage.inputTokens,
278
+ output_tokens: usage.outputTokens,
279
+ cache_read_input_tokens: usage.cacheReadTokens,
280
+ cache_creation_input_tokens: usage.cacheWriteTokens,
281
+ total_input_tokens: usage.inputTokens + usage.cacheInputDeltaTokens,
282
+ total_tokens: usage.inputTokens + usage.cacheInputDeltaTokens + usage.outputTokens,
283
+ };
284
+ const result = {
285
+ type: 'result',
286
+ subtype: failed ? 'error' : 'success',
287
+ session_id: sessionId,
288
+ turn_id: engineTurnId || clientTurnId,
289
+ model,
290
+ model_effort: modelEffort,
291
+ stop_reason: failed ? 'error' : stopReason,
292
+ is_error: !!failed,
293
+ result: resultText,
294
+ usage: finalUsage,
295
+ ...(failed ? { error: failed.message || String(failed) } : {}),
296
+ };
297
+ write(result);
298
+ return result;
299
+ }