shmakk 1.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.
package/src/session.js ADDED
@@ -0,0 +1,604 @@
1
+ // Session state machine extracted from orchestrator.js.
2
+ // Manages one shmakk session: PTY lifecycle, workspace tracking, output
3
+ // buffering, command correction, and agent invocation.
4
+
5
+ const { startSession } = require('./pty');
6
+ const { correct } = require('./correction');
7
+ const { runAgent, clearTaskJournal } = require('./agent');
8
+ const { clearIndex } = require('./workspace-index');
9
+ const { loadGlossary } = require('./glossary');
10
+ const { isConfigured } = require('./llm');
11
+ const { makePrompter, decisionBanner } = require('./review');
12
+ const { workspaceWarning } = require('./safety');
13
+ const audit = require('./audit');
14
+ const { setMaxListeners } = require('events');
15
+
16
+ // Lazy-loaded voice service — only required when --voice is active
17
+ let voiceService = null;
18
+ function getVoiceService() {
19
+ if (!voiceService) voiceService = require('./services/voice');
20
+ return voiceService;
21
+ }
22
+
23
+ // Lazy-loaded TTS service — only required when --tts is active
24
+ let ttsService = null;
25
+ function getTTSService() {
26
+ if (!ttsService) ttsService = require('./services/tts');
27
+ return ttsService;
28
+ }
29
+
30
+ const ALT_SCREEN_RE = /\x1b\[\?(?:1049|47|1047)h/;
31
+ const FLUSH_AFTER_MS = 300;
32
+ const FLUSH_AFTER_BYTES = 8 * 1024;
33
+
34
+ // Cap on conversation history kept between agent runs (entries past this
35
+ // limit are dropped from the front, preserving the most recent context).
36
+ const HISTORY_MAX_ENTRIES = 30;
37
+
38
+ function isAbortError(e) {
39
+ return e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')));
40
+ }
41
+
42
+ function stripAnsi(s) {
43
+ return String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
44
+ }
45
+
46
+ function trimHistory(history) {
47
+ if (history.length <= HISTORY_MAX_ENTRIES) return history;
48
+ // Drop oldest, but keep tool_call/tool pairs intact: walk from the end
49
+ // and stop at HISTORY_MAX_ENTRIES boundary that doesn't split a pair.
50
+ let cut = history.length - HISTORY_MAX_ENTRIES;
51
+ while (cut > 0 && history[cut].role === 'tool') cut--;
52
+ return history.slice(cut);
53
+ }
54
+
55
+ // Returns a confirmTool fn for the agent.
56
+ function makeToolConfirm(opts, ask, out, getAbort) {
57
+ return async ({ name, args, safety, description }) => {
58
+ audit.append({ kind: 'tool-proposed', name, args, safety, mode: opts.review ? 'review' : 'auto' });
59
+ const fileCreateAllowed = opts.yesFiles
60
+ && (name === 'write_file' || name === 'edit_file' || name === 'make_dir')
61
+ && safety !== 'unsafe';
62
+ const wouldAuto = safety === 'safe' || fileCreateAllowed;
63
+ if (!opts.review && wouldAuto) {
64
+ audit.append({ kind: 'tool-allowed', name, args, via: fileCreateAllowed ? 'yes-files' : 'auto-safe' });
65
+ return true;
66
+ }
67
+ out([
68
+ '\x1b[36m── shmakk tool ──\x1b[0m',
69
+ ` action: ${description}`,
70
+ ` safety: ${safety}`,
71
+ ` auto-mode: ${wouldAuto ? 'would auto-run' : 'would ask confirmation'}`,
72
+ '',
73
+ ].join('\r\n'));
74
+ const whyText = [
75
+ '',
76
+ '\x1b[36mWhy this tool?\x1b[0m',
77
+ `- The agent needs to: ${description}`,
78
+ `- Safety classification: ${safety}`,
79
+ `- Auto-mode policy: ${wouldAuto ? 'would auto-run in this mode' : 'requires confirmation in this mode'}`,
80
+ '- This action is required to continue the current task.',
81
+ '',
82
+ ].join('\r\n');
83
+ const ok = await ask('Run?', wouldAuto, {
84
+ onCancel: getAbort,
85
+ onWhy: () => out(whyText),
86
+ });
87
+ audit.append({ kind: ok ? 'tool-allowed' : 'tool-declined', name, args });
88
+ return ok;
89
+ };
90
+ }
91
+
92
+ async function runOneSession(opts, registerSession) {
93
+ const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice });
94
+ const colorsEnabled = opts.colors !== false;
95
+ const out = (s) => session.stdoutWrite(colorsEnabled ? s : stripAnsi(s));
96
+ const ask = makePrompter(session, out);
97
+ const glossary = loadGlossary();
98
+ // Workspace tracking: explicit --workspace is "pinned"; otherwise cwd
99
+ // floats with the inner shell's `cd`. When both pinned and cwd differ,
100
+ // both are passed as allowed roots.
101
+ const pinnedWorkspace = opts.workspace ? require('path').resolve(opts.workspace) : null;
102
+ let cwd = pinnedWorkspace || process.cwd();
103
+
104
+ function currentRoots() {
105
+ if (!pinnedWorkspace) return [require('path').resolve(cwd)];
106
+ const c = require('path').resolve(cwd);
107
+ return c === pinnedWorkspace ? [pinnedWorkspace] : [pinnedWorkspace, c];
108
+ }
109
+
110
+ const wsWarn = workspaceWarning(cwd);
111
+ if (wsWarn) out(`\x1b[33m[shmakk] ${wsWarn}\x1b[0m\r\n`);
112
+ if (!isConfigured()) {
113
+ out('\x1b[33m[shmakk] note: SHMAKK_BASE_URL not set — running as plain PTY (no AI).\x1b[0m\r\n');
114
+ } else if (!glossary) {
115
+ out('\x1b[33m[shmakk] tip: run `shmakk --update-command-glossary` for better corrections.\x1b[0m\r\n');
116
+ }
117
+ audit.append({ kind: 'session-start', workspace: cwd, pinnedWorkspace, review: !!opts.review, pid: process.pid });
118
+
119
+ // ── Global Ctrl+C handler (persistent bottom-of-stack) ──
120
+ // Sits below any AI-task handler. When no AI is running, this is the
121
+ // top handler and intercepts Ctrl+C to:
122
+ // 1. Stop TTS playback if active
123
+ // 2. Kill voice recording if in progress
124
+ // 3. On a second Ctrl+C within 2s while --sts is active, exit the
125
+ // always-on voice loop so the user is left at a normal shell prompt.
126
+ // If none apply, pass through to the child shell.
127
+ // Ctrl+C = shut up. Kills TTS, recorder, and voice loop. Always.
128
+ // Ctrl+D exits the shell as normal (we never touch it).
129
+ session.captureStdin((data) => {
130
+ for (let i = 0; i < data.length; i++) {
131
+ if (data[i] === 0x03 && (opts.tts || opts.stt || opts.sts)) {
132
+ try { fullVoiceTeardown(); } catch {}
133
+ if (i > 0) session.childWrite(data.slice(0, i));
134
+ session.childWrite('\r');
135
+ return;
136
+ }
137
+ }
138
+ session.childWrite(data);
139
+ });
140
+
141
+ // Conversation history — persists across agent invocations within one
142
+ // session so follow-up questions like "now check the imports" make sense.
143
+ let history = [];
144
+
145
+ // command lifecycle state
146
+ let lastCommand = null;
147
+ let bufferMode = false;
148
+ let pending = Buffer.alloc(0);
149
+ let bufferStart = 0;
150
+ let flushTimer = null;
151
+
152
+ // When a correction is applied, store the original failed command so that
153
+ // if the corrected command succeeds the agent still runs to handle the
154
+ // user's broader intent rather than dropping back to the prompt.
155
+ let correctionOrigin = null;
156
+
157
+ function flushPending() {
158
+ if (pending.length) { out(pending.toString('utf8')); pending = Buffer.alloc(0); }
159
+ bufferMode = false;
160
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
161
+ }
162
+ function discardPending() {
163
+ pending = Buffer.alloc(0);
164
+ bufferMode = false;
165
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
166
+ }
167
+
168
+ // ── Ctrl-C-aware AI work wrapper ──
169
+ // Installs a stdin tap that watches for 0x03 → aborts the controller.
170
+ // Other bytes pass through to the shell so the user can keep typing.
171
+ // In STS mode, Ctrl+C also tears down TTS, recording, and the voice
172
+ // loop — otherwise the user is trapped because this handler sits on
173
+ // top of the global one and would normally eat the keypress.
174
+ async function withAI(fn) {
175
+ const ctrl = new AbortController();
176
+ setMaxListeners(0, ctrl.signal);
177
+ const release = session.captureStdin((data) => {
178
+ let cut = -1;
179
+ for (let i = 0; i < data.length; i++) {
180
+ if (data[i] === 0x03) { cut = i; break; }
181
+ }
182
+ if (cut === -1) {
183
+ session.childWrite(data);
184
+ return;
185
+ }
186
+ if (cut > 0) session.childWrite(data.slice(0, cut));
187
+ if (opts.sts || opts.tts || opts.stt) {
188
+ try { fullVoiceTeardown(); } catch {}
189
+ }
190
+ ctrl.abort(new Error('interrupted'));
191
+ });
192
+ try {
193
+ return await fn(ctrl);
194
+ } finally {
195
+ release();
196
+ }
197
+ }
198
+
199
+ // Single-press emergency stop for all voice machinery. Called by both
200
+ // the global Ctrl+C handler and the withAI handler so the user can
201
+ // escape no matter which one is on top of the stdin stack.
202
+ function fullVoiceTeardown() {
203
+ try {
204
+ if (opts.tts || opts.sts) {
205
+ const tts = getTTSService();
206
+ const vs = getVoiceService();
207
+ vs._killTts();
208
+ tts.stopSpeaking();
209
+ }
210
+ if (opts.stt || opts.sts) {
211
+ getVoiceService()._killRecorder();
212
+ }
213
+ if (session._stsFlags) {
214
+ session._stsFlags.setTtsSpeaking(false);
215
+ if (session._stsFlags.stopLoop) session._stsFlags.stopLoop();
216
+ }
217
+ } catch {}
218
+ }
219
+
220
+ session.ev.on('cwd', (p) => { if (p) cwd = p; });
221
+ function resetHistory() {
222
+ history = [];
223
+ try { clearTaskJournal(currentRoots()[0]); } catch {}
224
+ try { clearIndex(currentRoots()[0]); } catch {}
225
+ out('\r\n\x1b[33m[shmakk] conversation + task journal + workspace index cleared\x1b[0m\r\n');
226
+ }
227
+ registerSession(session, resetHistory);
228
+
229
+ // ── Voice-as-agent-task helper ──
230
+ // Routes transcribed speech straight to the LLM agent and (optionally)
231
+ // speaks the response. Bypasses the shell entirely, so transcripts never
232
+ // get executed as commands or run through the correction engine.
233
+ let voiceTaskRunning = false;
234
+ async function runVoiceAsTask(text) {
235
+ if (!text || voiceTaskRunning) return;
236
+ if (!isConfigured()) {
237
+ out('\r\n\x1b[33m[shmakk] LLM not configured — voice input ignored\x1b[0m\r\n');
238
+ return;
239
+ }
240
+ voiceTaskRunning = true;
241
+ try {
242
+ await withAI(async (ctrl) => {
243
+ out('\x1b[36m[shmakk voice→task] (Ctrl-C to interrupt)\x1b[0m\r\n');
244
+ try {
245
+ const updated = await runAgent({
246
+ input: text, roots: currentRoots(), glossary,
247
+ confirmTool: makeToolConfirm(opts, ask, out, () => ctrl.abort()),
248
+ write: out,
249
+ signal: ctrl.signal,
250
+ history,
251
+ profile: opts.profile,
252
+ colors: colorsEnabled,
253
+ });
254
+ history = trimHistory(updated || history);
255
+ if ((opts.tts || opts.sts) && updated && updated.length) {
256
+ const lastAssistant = [...updated].reverse().find((m) => m.role === 'assistant');
257
+ if (lastAssistant?.content) {
258
+ const reply = typeof lastAssistant.content === 'string'
259
+ ? lastAssistant.content
260
+ : lastAssistant.content.map((c) => c.text || '').join(' ');
261
+ if (reply) {
262
+ if (opts.sts || opts.stt) {
263
+ try { getVoiceService()._killRecorder(); } catch {}
264
+ }
265
+ if (session._stsFlags) session._stsFlags.setTtsSpeaking(true);
266
+ session._ttsGen = (session._ttsGen || 0) + 1;
267
+ const myGen = session._ttsGen;
268
+ const ttsVoice = opts.ttsVoice || process.env.SHMAKK_TTS_VOICE || 'af_heart';
269
+ const tts = getTTSService();
270
+ const settle = (err) => {
271
+ if (session._ttsGen !== myGen) return;
272
+ if (session._stsFlags) session._stsFlags.setTtsSpeaking(false);
273
+ if (err && opts.debug) process.stderr.write(`[shmakk] tts: ${err.message}\n`);
274
+ };
275
+ tts.speak(reply, { voice: ttsVoice }).then(() => settle()).catch(settle);
276
+ }
277
+ }
278
+ }
279
+ session.childWrite('\r');
280
+ } catch (e) {
281
+ if (isAbortError(e)) out('\r\n\x1b[33m[shmakk] interrupted\x1b[0m\r\n');
282
+ else out(`\r\n[shmakk] task error: ${e.message}\r\n`);
283
+ }
284
+ });
285
+ } finally {
286
+ voiceTaskRunning = false;
287
+ }
288
+ }
289
+
290
+ // ── Continuous voice loop (--sts always-on) ──
291
+ // When --sts is active, runs a background loop: listen → transcribe → inject.
292
+ // No hotkey needed — just speak and pause.
293
+ // Pauses while TTS is speaking to avoid feedback loop.
294
+ if (opts.sts) {
295
+ const vs = getVoiceService();
296
+ if (!vs.isAvailable()) {
297
+ out('\r\n\x1b[33m[shmakk] no audio recorder found. Install sox.\x1b[0m\r\n');
298
+ } else {
299
+ // Preload STT model in background so first transcription doesn't lag
300
+ try { vs.preloadSTT(); } catch {}
301
+ let voiceLoopActive = true;
302
+ let voiceBusy = false;
303
+ // Set when TTS starts, cleared when TTS finishes — voice loop pauses
304
+ let ttsSpeaking = false;
305
+ let ttsStoppedAt = 0; // last time TTS finished (for cooldown)
306
+
307
+ const voiceLoop = async () => {
308
+ while (voiceLoopActive) {
309
+ if (voiceBusy) { await new Promise(r => setTimeout(r, 100)); continue; }
310
+ // Pause recording while TTS is playing to avoid feedback loop.
311
+ // User can interrupt TTS by pressing Ctrl+C (handled globally).
312
+ if (ttsSpeaking) { await new Promise(r => setTimeout(r, 200)); continue; }
313
+ // Small cooldown after TTS stops — avoids picking up reverb
314
+ if (Date.now() - ttsStoppedAt < 1200) { await new Promise(r => setTimeout(r, 100)); continue; }
315
+ voiceBusy = true;
316
+ try {
317
+ const text = await vs.recordAndTranscribe({
318
+ language: opts.voiceLanguage || process.env.SHMAKK_VOICE_LANGUAGE || 'english',
319
+ maxDurationSec: parseInt(opts.voiceMaxDuration || process.env.SHMAKK_VOICE_MAX_SEC || '30', 10),
320
+ });
321
+ if (text && voiceLoopActive) {
322
+ // Send straight to the LLM agent — do NOT inject into the
323
+ // shell. Otherwise the correction engine will rewrite
324
+ // mis-transcribed fragments into real commands.
325
+ await runVoiceAsTask(text);
326
+ }
327
+ } catch (err) {
328
+ if (opts.debug) process.stderr.write(`[shmakk voice] ${err.message}\n`);
329
+ await new Promise(r => setTimeout(r, 1000));
330
+ } finally {
331
+ voiceBusy = false;
332
+ }
333
+ }
334
+ };
335
+
336
+ // Start the loop detached
337
+ voiceLoop().catch(() => {});
338
+
339
+ // Stop loop on session exit
340
+ session.waitExit().then(() => { voiceLoopActive = false; });
341
+
342
+ // Expose setter for TTS module to pause/resume voice loop
343
+ // (used in the exit handler where TTS is launched)
344
+ if (!session._stsFlags) session._stsFlags = {};
345
+ session._stsFlags.setTtsSpeaking = (v) => { ttsSpeaking = v; if (!v) ttsStoppedAt = Date.now(); };
346
+ // Let the global Ctrl+C handler stop the STS loop on double-press.
347
+ session._stsFlags.stopLoop = () => { voiceLoopActive = false; };
348
+ }
349
+ }
350
+
351
+ // ── Voice input handler (Ctrl+O hotkey) ──
352
+ // Only active when --voice/--stt is passed without --sts.
353
+ let voiceInProgress = false;
354
+ if (opts.voice && !opts.sts) {
355
+ const voiceWarned = { mic: false };
356
+ session.ev.on('voice', async () => {
357
+ if (voiceInProgress) return;
358
+ voiceInProgress = true;
359
+ try {
360
+ const vs = getVoiceService();
361
+ if (!voiceWarned.mic) {
362
+ if (!vs.isAvailable()) {
363
+ out('\r\n\x1b[33m[shmakk voice] no microphone found. Install sox/arecord.\x1b[0m\r\n');
364
+ voiceInProgress = false;
365
+ return;
366
+ }
367
+ voiceWarned.mic = true;
368
+ }
369
+ // Show recording indicator — stays visible until transcription starts
370
+ out('\r\n\x1b[36m🎤 [shmakk] Listening... (speak now, stops on silence)\x1b[0m');
371
+ // Use a handler on the stdin stack so Ctrl-C aborts recording
372
+ let recordingDone = false;
373
+ const release = session.captureStdin((data) => {
374
+ for (let i = 0; i < data.length; i++) {
375
+ if (data[i] === 0x03 || data[i] === 0x0f) {
376
+ recordingDone = true;
377
+ // Kill the recorder process immediately
378
+ try { vs._killRecorder(); } catch {}
379
+ release();
380
+ return;
381
+ }
382
+ }
383
+ session.childWrite(data);
384
+ });
385
+ const text = await vs.recordAndTranscribe({
386
+ maxDurationSec: parseInt(opts.voiceMaxDuration || process.env.SHMAKK_VOICE_MAX_SEC || '10', 10),
387
+ language: opts.voiceLanguage || process.env.SHMAKK_VOICE_LANGUAGE,
388
+ onStart: () => {},
389
+ onStop: () => {
390
+ recordingDone = true;
391
+ try { release(); } catch {}
392
+ },
393
+ });
394
+ if (text) {
395
+ // Route to the agent, not the shell, so the correction engine
396
+ // doesn't try to turn transcripts into commands.
397
+ await runVoiceAsTask(text);
398
+ } else {
399
+ out('\r\x1b[33m[shmakk] no speech detected\x1b[0m\r\n');
400
+ }
401
+ } catch (err) {
402
+ out(`\r\x1b[31m[shmakk voice] ${err.message}\x1b[0m\r\n`);
403
+ if (opts.debug) out(`\r\x1b[33m${err.stack}\x1b[0m\r\n`);
404
+ } finally {
405
+ voiceInProgress = false;
406
+ }
407
+ });
408
+ }
409
+
410
+ session.ev.on('command', (c) => {
411
+ lastCommand = c;
412
+ if (!isConfigured() || opts.noAi) return;
413
+ bufferMode = true;
414
+ pending = Buffer.alloc(0);
415
+ bufferStart = Date.now();
416
+ if (flushTimer) clearTimeout(flushTimer);
417
+ flushTimer = setTimeout(() => { if (bufferMode) flushPending(); }, FLUSH_AFTER_MS);
418
+ });
419
+
420
+ session.ev.on('output', (buf) => {
421
+ if (!bufferMode) { out(buf.toString('utf8')); return; }
422
+ pending = Buffer.concat([pending, buf]);
423
+ const s = pending.toString('utf8');
424
+ if (ALT_SCREEN_RE.test(s) || pending.length > FLUSH_AFTER_BYTES || (Date.now() - bufferStart) > FLUSH_AFTER_MS) {
425
+ flushPending();
426
+ }
427
+ });
428
+
429
+ session.ev.on('exit', async (code) => {
430
+ const lastCmd = lastCommand;
431
+ const wasBuffered = bufferMode;
432
+ lastCommand = null;
433
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
434
+
435
+ // Determine the command to feed forward. Normally this is the failed
436
+ // command, but when a correction was applied and succeeded we use the
437
+ // user's original input so the agent handles their broader intent.
438
+ let cmd = lastCmd;
439
+ if (code === 0) {
440
+ if (correctionOrigin && !opts.noAi) {
441
+ cmd = correctionOrigin;
442
+ correctionOrigin = null;
443
+ } else {
444
+ flushPending();
445
+ return;
446
+ }
447
+ } else if (opts.noAi) {
448
+ flushPending();
449
+ return;
450
+ }
451
+
452
+ audit.append({ kind: 'failed-command', cmd, exit: code, cwd });
453
+
454
+ // ── Correction runs standalone (no LLM needed) ──
455
+ let decision;
456
+ if (opts.noCorrection) {
457
+ decision = { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'correction disabled' };
458
+ } else {
459
+ try {
460
+ decision = await correct({ input: cmd, glossary });
461
+ } catch (e) {
462
+ if (opts.debug) out(`\r\n\x1b[33m[shmakk] correction error: ${e.message}\x1b[0m\r\n`);
463
+ decision = { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: `correction failed: ${e.message}` };
464
+ }
465
+ }
466
+ audit.append({ kind: 'correction-decision', cmd, decision });
467
+
468
+ // ─── Command correction branch ───
469
+ if (decision.category === 'command_correction' && decision.proposed) {
470
+ const safe = decision.safety === 'safe';
471
+ if (opts.review) {
472
+ flushPending();
473
+ out(decisionBanner({ input: cmd, decision, mode: 'review' }));
474
+ const go = await ask('Run?', safe, {
475
+ onCancel: () => {},
476
+ onWhy: () => out([
477
+ '',
478
+ '\x1b[36mWhy this command correction?\x1b[0m',
479
+ `- Original command failed: ${cmd}`,
480
+ `- Proposed correction: ${decision.proposed}`,
481
+ `- Safety classification: ${decision.safety}`,
482
+ `- Reason: ${decision.reason || 'deterministic match'}`,
483
+ '',
484
+ ].join('\r\n')),
485
+ });
486
+ if (go) { correctionOrigin = cmd; audit.append({ kind: 'correction-run', proposed: decision.proposed }); session.childWrite(decision.proposed + '\r'); }
487
+ return;
488
+ }
489
+ // auto mode: safe + was buffered → silent correction
490
+ if (safe && wasBuffered) {
491
+ discardPending();
492
+ correctionOrigin = cmd;
493
+ audit.append({ kind: 'correction-run', proposed: decision.proposed, silent: true });
494
+ session.childWrite(decision.proposed + '\r');
495
+ return;
496
+ }
497
+ flushPending();
498
+ out(decisionBanner({ input: cmd, decision, mode: 'auto' }));
499
+ const go = await ask('Run?', false, {
500
+ onCancel: () => {},
501
+ onWhy: () => out([
502
+ '',
503
+ '\x1b[36mWhy this command correction?\x1b[0m',
504
+ `- Original command failed: ${cmd}`,
505
+ `- Proposed correction: ${decision.proposed}`,
506
+ `- Safety classification: ${decision.safety}`,
507
+ `- Reason: ${decision.reason || 'deterministic match'}`,
508
+ '',
509
+ ].join('\r\n')),
510
+ });
511
+ if (go) { correctionOrigin = cmd; audit.append({ kind: 'correction-run', proposed: decision.proposed }); session.childWrite(decision.proposed + '\r'); }
512
+ return;
513
+ }
514
+
515
+ // ─── Task branch (needs LLM) ───
516
+ if (!isConfigured()) {
517
+ flushPending();
518
+ out('\r\n\x1b[33m[shmakk] LLM not configured — no AI task available\x1b[0m\r\n');
519
+ return;
520
+ }
521
+
522
+ await withAI(async (ctrl) => {
523
+ if (opts.review || !wasBuffered) {
524
+ flushPending();
525
+ out(decisionBanner({ input: cmd, decision, mode: opts.review ? 'review' : 'auto' }));
526
+ if (opts.review) {
527
+ const go = await ask('Treat as task?', true, {
528
+ onCancel: () => ctrl.abort(),
529
+ onWhy: () => out([
530
+ '',
531
+ '\x1b[36mWhy treat this as a task?\x1b[0m',
532
+ `- Input did not resolve to a safe auto-correction path.`,
533
+ `- Category: ${decision.category}`,
534
+ `- Reason: ${decision.reason || 'No additional reason provided.'}`,
535
+ '- Running as a task lets the agent inspect files/tools and produce a concrete fix.',
536
+ '',
537
+ ].join('\r\n')),
538
+ });
539
+ if (!go) return;
540
+ }
541
+ } else {
542
+ discardPending();
543
+ }
544
+ out('\x1b[36m[shmakk task] (Ctrl-C to interrupt)\x1b[0m\r\n');
545
+ try {
546
+ const updated = await runAgent({
547
+ input: cmd, roots: currentRoots(), glossary,
548
+ confirmTool: makeToolConfirm(opts, ask, out, () => ctrl.abort()),
549
+ write: out,
550
+ signal: ctrl.signal,
551
+ history,
552
+ profile: opts.profile,
553
+ colors: colorsEnabled,
554
+ });
555
+ history = trimHistory(updated || history);
556
+
557
+ // TTS: speak the agent's response aloud if --tts is active
558
+ if (opts.tts && updated && updated.length) {
559
+ const lastAssistant = [...updated].reverse().find((m) => m.role === 'assistant');
560
+ if (lastAssistant?.content) {
561
+ const text = typeof lastAssistant.content === 'string'
562
+ ? lastAssistant.content
563
+ : lastAssistant.content.map((c) => c.text || '').join(' ');
564
+ if (text) {
565
+ // Interrupt any active voice recording so the mic doesn't
566
+ // pick up TTS audio as the next voice command.
567
+ if (opts.sts || opts.stt) {
568
+ try { getVoiceService()._killRecorder(); } catch {}
569
+ }
570
+ // Pause STS voice loop while TTS is speaking.
571
+ // Use a generation counter so only the latest speak's settle
572
+ // flips ttsSpeaking back to false — prevents an earlier,
573
+ // already-cancelled speak from unpausing the mic mid-sentence.
574
+ if (session._stsFlags) session._stsFlags.setTtsSpeaking(true);
575
+ session._ttsGen = (session._ttsGen || 0) + 1;
576
+ const myGen = session._ttsGen;
577
+ const ttsVoice = opts.ttsVoice || process.env.SHMAKK_TTS_VOICE || 'af_heart';
578
+ const tts = getTTSService();
579
+ const settle = (err) => {
580
+ if (session._ttsGen !== myGen) return;
581
+ if (session._stsFlags) session._stsFlags.setTtsSpeaking(false);
582
+ if (err && opts.debug) process.stderr.write(`[shmakk] tts: ${err.message}\n`);
583
+ };
584
+ tts.speak(text, { voice: ttsVoice }).then(() => settle()).catch(settle);
585
+ }
586
+ }
587
+ }
588
+
589
+ // Force the interactive shell to redraw its prompt so the user is
590
+ // returned cleanly to the terminal without needing to press Enter.
591
+ session.childWrite('\r');
592
+ } catch (e) {
593
+ if (isAbortError(e)) out('\r\n\x1b[33m[shmakk] interrupted\x1b[0m\r\n');
594
+ else out(`\r\n[shmakk] task error: ${e.message}\r\n`);
595
+ }
596
+ });
597
+ });
598
+
599
+ const { exitCode } = await session.waitExit();
600
+ audit.append({ kind: 'session-end', exitCode });
601
+ return exitCode;
602
+ }
603
+
604
+ module.exports = { runOneSession };