@markusylisiurunen/tau 0.2.35 → 0.2.37

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/README.md +63 -5
  2. package/dist/core/cli.js +20 -1
  3. package/dist/core/cli.js.map +1 -1
  4. package/dist/core/commands/registry.js +16 -0
  5. package/dist/core/commands/registry.js.map +1 -1
  6. package/dist/core/config/builtin_themes.js +31 -0
  7. package/dist/core/config/builtin_themes.js.map +1 -1
  8. package/dist/core/config/index.js +1 -1
  9. package/dist/core/config/index.js.map +1 -1
  10. package/dist/core/config/schema.js +24 -7
  11. package/dist/core/config/schema.js.map +1 -1
  12. package/dist/core/index.js +5 -2
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/modes/index.js +2 -0
  15. package/dist/core/modes/index.js.map +1 -1
  16. package/dist/core/modes/rpc_protocol.js +240 -0
  17. package/dist/core/modes/rpc_protocol.js.map +1 -0
  18. package/dist/core/modes/rpc_server.js +275 -0
  19. package/dist/core/modes/rpc_server.js.map +1 -0
  20. package/dist/core/runtime/chat_runtime.js +124 -0
  21. package/dist/core/runtime/chat_runtime.js.map +1 -0
  22. package/dist/core/runtime/conversation_turn_runtime.js +49 -0
  23. package/dist/core/runtime/conversation_turn_runtime.js.map +1 -0
  24. package/dist/core/runtime/session_prompt_composer.js +55 -0
  25. package/dist/core/runtime/session_prompt_composer.js.map +1 -0
  26. package/dist/core/utils/auto_retry.js +1 -1
  27. package/dist/core/utils/auto_retry.js.map +1 -1
  28. package/dist/core/version.js +1 -1
  29. package/dist/main.js +140 -5
  30. package/dist/main.js.map +1 -1
  31. package/dist/sdk/client.d.ts +2 -0
  32. package/dist/sdk/client.js +409 -0
  33. package/dist/sdk/client.js.map +1 -0
  34. package/dist/sdk/errors.d.ts +32 -0
  35. package/dist/sdk/errors.js +37 -0
  36. package/dist/sdk/errors.js.map +1 -0
  37. package/dist/sdk/index.d.ts +4 -0
  38. package/dist/sdk/index.js +3 -0
  39. package/dist/sdk/index.js.map +1 -0
  40. package/dist/sdk/types.d.ts +83 -0
  41. package/dist/sdk/types.js +2 -0
  42. package/dist/sdk/types.js.map +1 -0
  43. package/dist/tui/app.js.map +1 -1
  44. package/dist/tui/chat_controller.js +378 -101
  45. package/dist/tui/chat_controller.js.map +1 -1
  46. package/dist/tui/chat_view.js +59 -0
  47. package/dist/tui/chat_view.js.map +1 -1
  48. package/dist/tui/ui/custom_editor.js +12 -0
  49. package/dist/tui/ui/custom_editor.js.map +1 -1
  50. package/dist/tui/ui/theme/palette.js +1 -0
  51. package/dist/tui/ui/theme/palette.js.map +1 -1
  52. package/dist/tui/ui/theme/theme.js.map +1 -1
  53. package/package.json +11 -5
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { realpathSync, statSync } from "node:fs";
3
- import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { mkdtemp, readFile, unlink, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, relative, resolve, sep } from "node:path";
6
6
  import { formatCodexAuthError } from "../core/auth/auth_messages.js";
@@ -8,11 +8,10 @@ import { getAuthPath } from "../core/auth/auth_paths.js";
8
8
  import { AuthStorage } from "../core/auth/auth_storage.js";
9
9
  import { createCredentialResolver, } from "../core/auth/credential_resolver.js";
10
10
  import { createCommandRegistry, getRiskLevelDescription, } from "../core/commands/index.js";
11
- import { createDefaultConfigDeps, loadRuntimeConfig, } from "../core/config/index.js";
11
+ import { createDefaultConfigDeps, getMistralApiKey, loadRuntimeConfig, } from "../core/config/index.js";
12
+ import { ChatRuntime } from "../core/runtime/chat_runtime.js";
12
13
  import { createDefaultCoreDeps } from "../core/runtime/deps.js";
13
14
  import { createCheckpoint } from "../core/session/checkpoint.js";
14
- import { CoreSession } from "../core/session/core_session.js";
15
- import { formatSubagentsForPrompt, getSubagentBasePrompt } from "../core/subagents/registry.js";
16
15
  import { buildBashUiText, formatBashUserMessageText, getBashOutputPolicy, prepareBashOutput, } from "../core/tools/bash.js";
17
16
  import { ToolCatalog } from "../core/tools/catalog.js";
18
17
  import { createLocalToolExecutionBackend } from "../core/tools/execution_backend.js";
@@ -20,7 +19,7 @@ import { TOOL_NAME_BASH, TOOL_NAME_EDIT } from "../core/tools/tool_names.js";
20
19
  import { REASONING_LEVELS, } from "../core/types.js";
21
20
  import { resolveAgentCwd, resolveSandboxPath } from "../core/utils/agent_environment.js";
22
21
  import { findAgentsFilesFromCwdToHome } from "../core/utils/agents_files.js";
23
- import { buildBaseSystemPrompt, buildEnvironmentTag, buildProjectContextBlock, buildSandboxInfoBlock, buildSkillsIndexBlock, findAgentsFilesInScopeDetailed, formatCwdChangeNotice, formatRiskLevelChangeNotice, } from "../core/utils/context.js";
22
+ import { buildProjectContextBlock, buildSkillsIndexBlock, findAgentsFilesInScopeDetailed, formatCwdChangeNotice, formatRiskLevelChangeNotice, } from "../core/utils/context.js";
24
23
  import { formatAdaptiveNumber, formatCwd, formatTokenWindow } from "../core/utils/format.js";
25
24
  import { getGitRoot } from "../core/utils/git.js";
26
25
  import { buildLineDiff, collapseLongUnchangedDiffRuns } from "../core/utils/line_diff.js";
@@ -59,6 +58,11 @@ const PRUNED_EDIT_ARGUMENT_MARKER = "[content pruned]";
59
58
  const PRUNE_EDIT_UNCHANGED_CONTEXT_LINES = 4;
60
59
  const PRUNE_PREVIEW_MAX_TOKENS = 512;
61
60
  const PRUNE_MAX_OVERAGE_RATIO = 0.1;
61
+ const SPEAK_TEMP_FILE_TEMPLATE = "/tmp/tau-speak.XXXXXX";
62
+ const SPEAK_MISTRAL_TRANSCRIBE_MODEL = "voxtral-mini-latest";
63
+ const SPEAK_RECORDING_MIN_BYTES = 1024;
64
+ const SPEAK_RECORDING_MAX_DURATION_MS = 5 * 60 * 1000;
65
+ const CAFFEINATE_COMMAND = "/usr/bin/caffeinate";
62
66
  export class ChatController {
63
67
  view;
64
68
  personas;
@@ -73,9 +77,11 @@ export class ChatController {
73
77
  credentialResolver;
74
78
  authPath;
75
79
  sandboxEnabled;
80
+ caffeinated;
76
81
  sandboxRootReal;
77
82
  agentCwd;
78
83
  includeAgentContext;
84
+ runtime;
79
85
  engine;
80
86
  commandRegistry;
81
87
  commandHandlers;
@@ -93,9 +99,9 @@ export class ChatController {
93
99
  showThinking = false;
94
100
  compactToolUi = true;
95
101
  commandHint;
102
+ assistantTurnInterruptRequested = false;
96
103
  currentTurnAbort;
97
104
  riskLevel = "read-only";
98
- environmentTag = "";
99
105
  projectContextBlock;
100
106
  projectFiles = [];
101
107
  projectFilesCwd;
@@ -103,8 +109,6 @@ export class ChatController {
103
109
  isInFileAutocomplete = false;
104
110
  agentsFiles;
105
111
  agentsConfigErrors;
106
- baseSystemPrompt = "";
107
- subagentPrompts = {};
108
112
  pendingRiskLevelChange;
109
113
  pendingCwdChange;
110
114
  expandedFilesInCurrentPrompt = new Set();
@@ -114,6 +118,11 @@ export class ChatController {
114
118
  lastTurnDurationMs = 0;
115
119
  turnTimer;
116
120
  lastEmptySubmitAt;
121
+ speakRecording;
122
+ isTranscribingSpeak = false;
123
+ speakTransition;
124
+ turnCaffeinate;
125
+ disableCaffeinateForSession = false;
117
126
  constructor(options) {
118
127
  this.view = options.view;
119
128
  this.deps = options.deps ?? createDefaultCoreDeps();
@@ -129,7 +138,8 @@ export class ChatController {
129
138
  this.bashCommands = options.bashCommands ?? [];
130
139
  this.initialUserMessage = options.initialUserMessage;
131
140
  this.config = options.config ?? {};
132
- this.sandboxEnabled = options.sandboxEnabled ?? false;
141
+ this.sandboxEnabled = options.sandboxEnabled;
142
+ this.caffeinated = options.caffeinated ?? false;
133
143
  this.sandboxRootReal = this.sandboxEnabled ? this.resolveSandboxRoot(cwd) : undefined;
134
144
  this.agentCwd = resolveAgentCwd({
135
145
  cwd,
@@ -175,7 +185,6 @@ export class ChatController {
175
185
  this.riskLevel = options.initialRiskLevel;
176
186
  }
177
187
  const skillsContext = this.getSkillsIndexBlockForPersona(this.currentPersona);
178
- this.updateSystemPrompts(skillsContext.skillsBlock);
179
188
  this.toolBackend =
180
189
  options.toolBackend ??
181
190
  createLocalToolExecutionBackend({
@@ -186,15 +195,26 @@ export class ChatController {
186
195
  throw new Error("sandbox enabled but tool backend is not sandboxed.");
187
196
  }
188
197
  const toolRegistry = ToolCatalog.createRegistry(this.toolBackend);
189
- this.engine = new CoreSession({
198
+ this.runtime = ChatRuntime.create({
190
199
  persona: this.currentPersona,
191
- systemPrompt: this.baseSystemPrompt,
192
- subagentPrompts: this.subagentPrompts,
193
200
  riskLevel: this.riskLevel,
194
201
  toolRegistry,
202
+ promptContext: {
203
+ cwd: this.agentCwd,
204
+ projectContextBlock: this.projectContextBlock,
205
+ sandboxEnabled: this.sandboxEnabled,
206
+ sandboxEnvironmentInfo: this.config.sandbox?.environmentInfo,
207
+ skillsBlock: skillsContext.skillsBlock,
208
+ },
209
+ environment: {
210
+ now: () => this.deps.clock.now(),
211
+ platform: () => this.deps.env.platform(),
212
+ nodeVersion: () => this.deps.env.nodeVersion(),
213
+ },
195
214
  config: this.config,
196
215
  deps: this.deps,
197
216
  });
217
+ this.engine = this.runtime.session;
198
218
  this.subagentUnsubscribe = this.engine.onSubagentEvent((event) => this.onEvent(event));
199
219
  this.commandRegistry = createCommandRegistry();
200
220
  this.commandHandlers = {
@@ -211,6 +231,7 @@ export class ChatController {
211
231
  pruneLargest: (extra) => this.pruneToolResults("largest", extra),
212
232
  pruneSmart: (extra) => this.pruneToolResultsSmart(extra),
213
233
  reload: () => this.reloadContent(),
234
+ speak: () => this.toggleSpeakCapture(),
214
235
  risk: (level) => this.setRiskLevel(level),
215
236
  persona: (id) => this.switchPersona(id),
216
237
  prompt: (id) => this.insertPrompt(id),
@@ -263,6 +284,7 @@ export class ChatController {
263
284
  onCtrlR: () => this.cycleRiskLevel(),
264
285
  onCtrlP: () => this.cyclePersonality(),
265
286
  onCtrlS: () => void this.stashEditorToClipboard(),
287
+ onCtrlY: () => void this.toggleSpeakCapture(),
266
288
  onEscape: () => this.onInterrupt(),
267
289
  onCtrlF: () => {
268
290
  this.expandFileMentions().catch((err) => {
@@ -284,6 +306,11 @@ export class ChatController {
284
306
  }
285
307
  async dispose() {
286
308
  this.subagentUnsubscribe?.();
309
+ if (this.speakTransition) {
310
+ await this.speakTransition;
311
+ }
312
+ await this.cancelSpeakCapture();
313
+ await this.stopTurnCaffeinate();
287
314
  if (!this.toolBackendDispose)
288
315
  return;
289
316
  await this.toolBackendDispose();
@@ -293,6 +320,10 @@ export class ChatController {
293
320
  await this.handleSubmit(text);
294
321
  }
295
322
  onInterrupt() {
323
+ if (this.speakRecording) {
324
+ void this.runSpeakTransition(() => this.stopSpeakCapture());
325
+ return;
326
+ }
296
327
  this.interruptAssistantTurn();
297
328
  }
298
329
  onEvent(event) {
@@ -444,6 +475,8 @@ export class ChatController {
444
475
  });
445
476
  }
446
477
  getInputMode() {
478
+ if (this.speakRecording)
479
+ return "recording";
447
480
  if (this.isBashIncognito)
448
481
  return "bash_incognito";
449
482
  if (this.isBashMode)
@@ -760,13 +793,25 @@ export class ChatController {
760
793
  });
761
794
  }
762
795
  interruptAssistantTurn() {
763
- if (!this.isStreaming || this.currentTurnAbort?.signal.aborted)
796
+ if (!this.isStreaming)
797
+ return;
798
+ if (this.runtime.isTurnRunning) {
799
+ if (this.assistantTurnInterruptRequested)
800
+ return;
801
+ this.assistantTurnInterruptRequested = true;
802
+ this.runtime.interruptTurn();
803
+ this.view.addSystemMessage("interrupted", "error");
804
+ return;
805
+ }
806
+ if (this.currentTurnAbort?.signal.aborted)
764
807
  return;
765
808
  this.currentTurnAbort?.abort();
766
809
  this.view.addSystemMessage("interrupted", "error");
767
810
  }
768
811
  // Input Handling --------------------------------------------------------------------------------
769
812
  beforeSubmit(text) {
813
+ if (this.speakRecording)
814
+ return false;
770
815
  if (!this.isStreaming)
771
816
  return true;
772
817
  const trimmed = text.trimStart();
@@ -853,6 +898,8 @@ export class ChatController {
853
898
  return "prune smart-selected tool results and compact edit calls, optional fraction and guidance";
854
899
  case "reload":
855
900
  return "reload prompts, skills, themes, bash commands, and AGENTS.md";
901
+ case "speak":
902
+ return "toggle microphone recording and transcribe to editor";
856
903
  case "risk":
857
904
  return "set risk level: /risk:read-only or /risk:read-write";
858
905
  case "bash":
@@ -1089,6 +1136,258 @@ export class ChatController {
1089
1136
  this.view.addMessage({ type: "user", text: trimmed }, historyEntryId);
1090
1137
  await this.runAssistantTurn();
1091
1138
  }
1139
+ async toggleSpeakCapture() {
1140
+ if (this.speakTransition) {
1141
+ this.view.addSystemMessage("speech recording state change already in progress", "warn");
1142
+ return;
1143
+ }
1144
+ if (this.speakRecording) {
1145
+ await this.runSpeakTransition(() => this.stopSpeakCapture());
1146
+ return;
1147
+ }
1148
+ if (this.isTranscribingSpeak) {
1149
+ this.view.addSystemMessage("speech transcription already in progress", "warn");
1150
+ return;
1151
+ }
1152
+ if (this.isStreaming) {
1153
+ this.view.addSystemMessage("wait for the assistant to finish before recording", "warn");
1154
+ return;
1155
+ }
1156
+ await this.runSpeakTransition(() => this.startSpeakCapture());
1157
+ }
1158
+ async runSpeakTransition(task) {
1159
+ if (this.speakTransition) {
1160
+ return;
1161
+ }
1162
+ const transition = task();
1163
+ this.speakTransition = transition;
1164
+ try {
1165
+ await transition;
1166
+ }
1167
+ finally {
1168
+ if (this.speakTransition === transition) {
1169
+ this.speakTransition = undefined;
1170
+ }
1171
+ }
1172
+ }
1173
+ async startSpeakCapture() {
1174
+ const apiKey = getMistralApiKey(this.config, this.deps.env.env());
1175
+ if (!apiKey) {
1176
+ this.view.addSystemMessage("set MISTRAL_API_KEY or apiKeys.mistral to use /speak", "error");
1177
+ return;
1178
+ }
1179
+ let audioPath;
1180
+ try {
1181
+ audioPath = await this.createSpeakTempFilePath();
1182
+ const abortController = new AbortController();
1183
+ const completion = this.deps.spawn("ffmpeg", [
1184
+ "-hide_banner",
1185
+ "-loglevel",
1186
+ "error",
1187
+ "-nostdin",
1188
+ "-f",
1189
+ "avfoundation",
1190
+ "-i",
1191
+ ":0",
1192
+ "-ac",
1193
+ "1",
1194
+ "-ar",
1195
+ "16000",
1196
+ "-c:a",
1197
+ "pcm_s16le",
1198
+ "-f",
1199
+ "wav",
1200
+ "-y",
1201
+ audioPath,
1202
+ ], {
1203
+ detached: true,
1204
+ killProcessGroup: true,
1205
+ signal: abortController.signal,
1206
+ stdio: ["ignore", "ignore", "ignore"],
1207
+ });
1208
+ const recording = {
1209
+ audioPath,
1210
+ stopRequested: false,
1211
+ abortController,
1212
+ completion,
1213
+ };
1214
+ recording.maxDurationTimeout = setTimeout(() => {
1215
+ if (this.speakRecording !== recording || this.speakTransition)
1216
+ return;
1217
+ void this.runSpeakTransition(() => this.stopSpeakCapture());
1218
+ }, SPEAK_RECORDING_MAX_DURATION_MS);
1219
+ this.speakRecording = recording;
1220
+ this.view.setEditorInputEnabled(false);
1221
+ this.refreshStatus();
1222
+ void this.watchSpeakRecording(recording);
1223
+ }
1224
+ catch (err) {
1225
+ if (audioPath) {
1226
+ await this.cleanupSpeakTempFile(audioPath);
1227
+ }
1228
+ this.view.addSystemMessage(`failed to start recording: ${err.message}`, "error");
1229
+ }
1230
+ }
1231
+ async stopSpeakCapture() {
1232
+ const recording = this.speakRecording;
1233
+ if (!recording)
1234
+ return;
1235
+ recording.stopRequested = true;
1236
+ this.clearSpeakRecordingMaxDurationTimeout(recording);
1237
+ this.speakRecording = undefined;
1238
+ this.view.setEditorInputEnabled(true);
1239
+ this.refreshStatus();
1240
+ recording.abortController.abort();
1241
+ try {
1242
+ await recording.completion;
1243
+ }
1244
+ catch (err) {
1245
+ this.view.addSystemMessage(`recording failed: ${err.message}`, "error");
1246
+ await this.cleanupSpeakTempFile(recording.audioPath);
1247
+ return;
1248
+ }
1249
+ this.isTranscribingSpeak = true;
1250
+ try {
1251
+ const audio = await readFile(recording.audioPath);
1252
+ if (audio.byteLength < SPEAK_RECORDING_MIN_BYTES) {
1253
+ this.view.addSystemMessage("recording too short, try again", "warn");
1254
+ return;
1255
+ }
1256
+ const transcript = await this.transcribeSpeakAudio(audio);
1257
+ const text = transcript.trim();
1258
+ if (!text) {
1259
+ return;
1260
+ }
1261
+ this.view.insertEditorTextAtCursor(text);
1262
+ }
1263
+ catch (err) {
1264
+ this.view.addSystemMessage(`speech transcription failed: ${err.message}`, "error");
1265
+ }
1266
+ finally {
1267
+ this.isTranscribingSpeak = false;
1268
+ await this.cleanupSpeakTempFile(recording.audioPath);
1269
+ }
1270
+ }
1271
+ async cancelSpeakCapture() {
1272
+ const recording = this.speakRecording;
1273
+ if (!recording)
1274
+ return;
1275
+ recording.stopRequested = true;
1276
+ this.clearSpeakRecordingMaxDurationTimeout(recording);
1277
+ this.speakRecording = undefined;
1278
+ this.view.setEditorInputEnabled(true);
1279
+ this.refreshStatus();
1280
+ recording.abortController.abort();
1281
+ try {
1282
+ await recording.completion;
1283
+ }
1284
+ catch {
1285
+ // ignore disposal errors
1286
+ }
1287
+ await this.cleanupSpeakTempFile(recording.audioPath);
1288
+ }
1289
+ async watchSpeakRecording(recording) {
1290
+ try {
1291
+ const result = await recording.completion;
1292
+ this.clearSpeakRecordingMaxDurationTimeout(recording);
1293
+ if (this.speakRecording !== recording || recording.stopRequested)
1294
+ return;
1295
+ this.speakRecording = undefined;
1296
+ this.view.setEditorInputEnabled(true);
1297
+ this.refreshStatus();
1298
+ const detail = result.exitCode !== null
1299
+ ? `ffmpeg exited with code ${result.exitCode}`
1300
+ : result.closeSignal
1301
+ ? `ffmpeg terminated by signal ${result.closeSignal}`
1302
+ : "ffmpeg exited";
1303
+ this.view.addSystemMessage(`recording stopped unexpectedly (${detail})`, "error");
1304
+ await this.cleanupSpeakTempFile(recording.audioPath);
1305
+ }
1306
+ catch (err) {
1307
+ this.clearSpeakRecordingMaxDurationTimeout(recording);
1308
+ if (this.speakRecording !== recording || recording.stopRequested)
1309
+ return;
1310
+ this.speakRecording = undefined;
1311
+ this.view.setEditorInputEnabled(true);
1312
+ this.refreshStatus();
1313
+ const error = err;
1314
+ if (error.code === "ENOENT") {
1315
+ this.view.addSystemMessage("ffmpeg not found. install it with: brew install ffmpeg", "error");
1316
+ }
1317
+ else {
1318
+ this.view.addSystemMessage(`recording failed: ${error.message}`, "error");
1319
+ }
1320
+ await this.cleanupSpeakTempFile(recording.audioPath);
1321
+ }
1322
+ }
1323
+ clearSpeakRecordingMaxDurationTimeout(recording) {
1324
+ if (!recording.maxDurationTimeout)
1325
+ return;
1326
+ clearTimeout(recording.maxDurationTimeout);
1327
+ recording.maxDurationTimeout = undefined;
1328
+ }
1329
+ async createSpeakTempFilePath() {
1330
+ const result = await this.deps.spawn("mktemp", [SPEAK_TEMP_FILE_TEMPLATE]);
1331
+ if (result.exitCode !== 0) {
1332
+ const message = result.stderr.trim() || result.stdout.trim() || "mktemp failed";
1333
+ throw new Error(message);
1334
+ }
1335
+ const path = result.stdout.trim().split(/\r?\n/, 1)[0]?.trim();
1336
+ if (!path) {
1337
+ throw new Error("mktemp returned an empty path");
1338
+ }
1339
+ return path;
1340
+ }
1341
+ async transcribeSpeakAudio(audio) {
1342
+ const apiKey = getMistralApiKey(this.config, this.deps.env.env());
1343
+ if (!apiKey) {
1344
+ throw new Error("missing MISTRAL_API_KEY or apiKeys.mistral");
1345
+ }
1346
+ const formData = new FormData();
1347
+ formData.append("model", SPEAK_MISTRAL_TRANSCRIBE_MODEL);
1348
+ formData.append("file", new Blob([audio], { type: "audio/wav" }), "speech.wav");
1349
+ formData.append("language", "en");
1350
+ const response = await fetch("https://api.mistral.ai/v1/audio/transcriptions", {
1351
+ method: "POST",
1352
+ headers: {
1353
+ Authorization: `Bearer ${apiKey}`,
1354
+ },
1355
+ body: formData,
1356
+ });
1357
+ let payload;
1358
+ const responseText = await response.text();
1359
+ if (responseText) {
1360
+ try {
1361
+ payload = JSON.parse(responseText);
1362
+ }
1363
+ catch {
1364
+ payload = undefined;
1365
+ }
1366
+ }
1367
+ if (!response.ok) {
1368
+ const fromObject = payload && typeof payload === "object" && "message" in payload
1369
+ ? payload.message
1370
+ : undefined;
1371
+ const fromString = typeof fromObject === "string" ? fromObject : undefined;
1372
+ const fallback = responseText.trim() || `HTTP ${response.status}`;
1373
+ throw new Error(fromString || fallback);
1374
+ }
1375
+ const text = payload && typeof payload === "object" && "text" in payload
1376
+ ? payload.text
1377
+ : undefined;
1378
+ if (typeof text !== "string") {
1379
+ return "";
1380
+ }
1381
+ return text;
1382
+ }
1383
+ async cleanupSpeakTempFile(path) {
1384
+ try {
1385
+ await unlink(path);
1386
+ }
1387
+ catch {
1388
+ // best-effort cleanup
1389
+ }
1390
+ }
1092
1391
  getMemoryModeFilePath() {
1093
1392
  const cwd = this.deps.env.cwd();
1094
1393
  const home = this.deps.env.home();
@@ -1280,86 +1579,23 @@ export class ChatController {
1280
1579
  escapeXmlAttribute(text) {
1281
1580
  return this.escapeXml(text).replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1282
1581
  }
1283
- updateSystemPrompts(skillsBlock) {
1284
- const timestamp = new Date(this.deps.clock.now()).toISOString();
1285
- this.environmentTag = buildEnvironmentTag({
1286
- riskLevel: this.riskLevel,
1582
+ syncRuntimePromptContext() {
1583
+ this.runtime.updatePromptContext({
1287
1584
  cwd: this.agentCwd,
1288
- datetime: timestamp,
1289
- platform: this.deps.env.platform(),
1290
- nodeVersion: this.deps.env.nodeVersion(),
1291
- });
1292
- const resolvedSkillsBlock = skillsBlock ?? this.getSkillsIndexBlockForPersona(this.currentPersona).skillsBlock;
1293
- this.baseSystemPrompt = this.buildSystemPrompt({
1294
- persona: this.currentPersona,
1295
- skillsBlock: resolvedSkillsBlock,
1296
- });
1297
- this.subagentPrompts = this.buildSubagentPromptMap({
1298
- persona: this.currentPersona,
1299
- skillsBlock: resolvedSkillsBlock,
1300
- timestamp,
1301
- });
1302
- }
1303
- updateSubagentPrompts(skillsBlock) {
1304
- const timestamp = new Date(this.deps.clock.now()).toISOString();
1305
- const resolvedSkillsBlock = skillsBlock ?? this.getSkillsIndexBlockForPersona(this.currentPersona).skillsBlock;
1306
- this.subagentPrompts = this.buildSubagentPromptMap({
1307
- persona: this.currentPersona,
1308
- skillsBlock: resolvedSkillsBlock,
1309
- timestamp,
1585
+ projectContextBlock: this.projectContextBlock,
1586
+ sandboxEnabled: this.sandboxEnabled,
1587
+ sandboxEnvironmentInfo: this.config.sandbox?.environmentInfo,
1310
1588
  });
1311
1589
  }
1312
1590
  rebuildSystemPrompt(skillsBlock) {
1313
- this.updateSystemPrompts(skillsBlock);
1314
- this.engine.setPersona(this.currentPersona, this.baseSystemPrompt, this.subagentPrompts);
1591
+ const resolvedSkillsBlock = skillsBlock ?? this.getSkillsIndexBlockForPersona(this.currentPersona).skillsBlock;
1592
+ this.syncRuntimePromptContext();
1593
+ this.runtime.setPersona(this.currentPersona, { skillsBlock: resolvedSkillsBlock });
1315
1594
  }
1316
1595
  rebuildSubagentPrompts(skillsBlock) {
1317
- this.updateSubagentPrompts(skillsBlock);
1318
- this.engine.setPersona(this.currentPersona, this.baseSystemPrompt, this.subagentPrompts);
1319
- }
1320
- buildSystemPrompt(args) {
1321
- return buildBaseSystemPrompt({
1322
- personaSystemPrompt: args.persona.systemPrompt,
1323
- skillsBlock: args.skillsBlock,
1324
- projectContextBlock: this.projectContextBlock,
1325
- sandboxInfoBlock: this.sandboxEnabled
1326
- ? buildSandboxInfoBlock(this.config.sandbox?.environmentInfo)
1327
- : undefined,
1328
- environmentTag: this.environmentTag,
1329
- subagentsBlock: formatSubagentsForPrompt(args.persona),
1330
- });
1331
- }
1332
- buildSubagentPromptMap(args) {
1333
- const subagents = args.persona.subagents;
1334
- if (!subagents || Object.keys(subagents).length === 0) {
1335
- return {};
1336
- }
1337
- const sandboxInfoBlock = this.sandboxEnabled
1338
- ? buildSandboxInfoBlock(this.config.sandbox?.environmentInfo)
1339
- : undefined;
1340
- const prompts = {};
1341
- for (const [name, cfg] of Object.entries(subagents)) {
1342
- if (!cfg)
1343
- continue;
1344
- const basePrompt = getSubagentBasePrompt(name, cfg);
1345
- if (!basePrompt)
1346
- continue;
1347
- const environmentTag = buildEnvironmentTag({
1348
- riskLevel: cfg.riskLevel ?? this.riskLevel,
1349
- cwd: this.agentCwd,
1350
- datetime: args.timestamp,
1351
- platform: this.deps.env.platform(),
1352
- nodeVersion: this.deps.env.nodeVersion(),
1353
- });
1354
- prompts[name] = buildBaseSystemPrompt({
1355
- personaSystemPrompt: basePrompt,
1356
- skillsBlock: args.skillsBlock,
1357
- projectContextBlock: this.projectContextBlock,
1358
- sandboxInfoBlock,
1359
- environmentTag,
1360
- });
1361
- }
1362
- return prompts;
1596
+ const resolvedSkillsBlock = skillsBlock ?? this.getSkillsIndexBlockForPersona(this.currentPersona).skillsBlock;
1597
+ this.syncRuntimePromptContext();
1598
+ this.runtime.rebuildSubagentPrompts({ skillsBlock: resolvedSkillsBlock });
1363
1599
  }
1364
1600
  applyCompactedHistoryUi(compactionMessage) {
1365
1601
  this.view.resetToolUiSession();
@@ -2181,10 +2417,10 @@ export class ChatController {
2181
2417
  setRiskLevel(level, options) {
2182
2418
  const previous = this.riskLevel;
2183
2419
  this.riskLevel = level;
2184
- this.engine.setRiskLevel(level);
2420
+ this.syncRuntimePromptContext();
2421
+ this.runtime.setRiskLevel(level);
2185
2422
  this.refreshStatus();
2186
2423
  if (previous !== level) {
2187
- this.rebuildSubagentPrompts();
2188
2424
  const from = this.pendingRiskLevelChange?.from ?? previous;
2189
2425
  if (from === level) {
2190
2426
  this.pendingRiskLevelChange = undefined;
@@ -2271,7 +2507,7 @@ export class ChatController {
2271
2507
  const previousThemeId = this.activeThemeId ?? this.config.defaultTheme;
2272
2508
  const messages = [];
2273
2509
  this.config = runtime.config;
2274
- this.engine.setConfig(this.config);
2510
+ this.runtime.setConfig(this.config);
2275
2511
  if (plan.bashCommands) {
2276
2512
  this.bashCommands = runtime.bashCommands;
2277
2513
  }
@@ -2390,36 +2626,77 @@ export class ChatController {
2390
2626
  this.view.addSystemMessage(`reload failed: ${err.message}`, "error");
2391
2627
  }
2392
2628
  }
2629
+ startTurnCaffeinate() {
2630
+ if (!this.caffeinated || this.disableCaffeinateForSession || this.turnCaffeinate) {
2631
+ return;
2632
+ }
2633
+ if (this.deps.env.platform() !== "darwin") {
2634
+ this.disableCaffeinateForSession = true;
2635
+ this.view.addSystemMessage("--caffeinated is only supported on macOS.", "warn");
2636
+ return;
2637
+ }
2638
+ const abortController = new AbortController();
2639
+ const completion = this.deps.spawn(CAFFEINATE_COMMAND, ["-i"], {
2640
+ detached: true,
2641
+ killProcessGroup: true,
2642
+ signal: abortController.signal,
2643
+ stdio: ["ignore", "ignore", "ignore"],
2644
+ });
2645
+ completion.catch(() => { });
2646
+ this.turnCaffeinate = {
2647
+ abortController,
2648
+ completion,
2649
+ };
2650
+ }
2651
+ async stopTurnCaffeinate() {
2652
+ const session = this.turnCaffeinate;
2653
+ if (!session)
2654
+ return;
2655
+ this.turnCaffeinate = undefined;
2656
+ if (!session.abortController.signal.aborted) {
2657
+ session.abortController.abort();
2658
+ }
2659
+ try {
2660
+ await session.completion;
2661
+ }
2662
+ catch (err) {
2663
+ if (this.disableCaffeinateForSession) {
2664
+ return;
2665
+ }
2666
+ this.disableCaffeinateForSession = true;
2667
+ const error = err;
2668
+ const details = error.message || "unknown error";
2669
+ this.view.addSystemMessage(`failed to run caffeinate: ${details}`, "warn");
2670
+ }
2671
+ }
2393
2672
  // Assistant Turn --------------------------------------------------------------------------------
2394
2673
  async runAssistantTurn() {
2395
2674
  this.isStreaming = true;
2396
- this.currentTurnAbort = new AbortController();
2397
2675
  this.view.startWorkingIcon();
2398
2676
  this.startTurnTimer();
2399
2677
  this.assistantState = undefined;
2678
+ this.assistantTurnInterruptRequested = false;
2679
+ this.startTurnCaffeinate();
2680
+ let runResult = { aborted: false };
2400
2681
  try {
2401
- for await (const event of this.engine.events(this.currentTurnAbort.signal)) {
2402
- if (this.currentTurnAbort.signal.aborted)
2403
- break;
2404
- this.onEvent(event);
2405
- }
2682
+ runResult = await this.runtime.runTurn((event) => this.onEvent(event));
2406
2683
  }
2407
2684
  catch (err) {
2408
2685
  const message = err.message || "request failed";
2409
2686
  this.view.addSystemMessage(message, "error");
2410
2687
  }
2411
2688
  finally {
2412
- const wasAborted = this.currentTurnAbort?.signal.aborted ?? false;
2413
- const reason = wasAborted ? "aborted" : "interrupted";
2689
+ await this.stopTurnCaffeinate();
2690
+ const reason = runResult.aborted ? "aborted" : "interrupted";
2414
2691
  this.view.finalizeToolUiPending(reason);
2415
2692
  this.view.stopWorkingIcon();
2416
2693
  this.stopTurnTimer();
2417
2694
  this.isStreaming = false;
2418
- this.currentTurnAbort = undefined;
2695
+ this.assistantTurnInterruptRequested = false;
2419
2696
  this.view.clearToolUiTransientState();
2420
2697
  this.pendingIdleNotification = true;
2421
2698
  this.view.requestRender();
2422
- if (wasAborted) {
2699
+ if (runResult.aborted) {
2423
2700
  this.dequeueQueuedUserMessagesIntoEditor();
2424
2701
  }
2425
2702
  else {