@phenx-inc/ctlsurf 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/out/headless/index.mjs +169 -10
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +175 -9
  4. package/out/preload/index.js +10 -0
  5. package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
  11. package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
  12. package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/index.ts +9 -0
  28. package/src/main/orchestrator.ts +51 -10
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +14 -0
  31. package/src/renderer/App.tsx +30 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/SpeakControls.tsx +235 -0
  34. package/src/renderer/components/VoiceInput.tsx +183 -140
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +94 -6
  37. package/src/renderer/lib/speech.ts +317 -0
  38. package/src/renderer/styles.css +229 -0
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-dRvutfbl.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-Cj7K9-Ba.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-dRvutfbl.js";
1
+ import { l as languages } from "./index-Cj7K9-Ba.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,10 +4,72 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ctlsurf-worker</title>
7
- <script type="module" crossorigin src="./assets/index-dRvutfbl.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-Cf-RsxoC.css">
7
+ <!-- Boot splash styles are inlined here so they paint on the first frame,
8
+ before the JS bundle and styles.css have loaded. Without this the window
9
+ shows an empty dark pane while React mounts and the startup IPC runs. -->
10
+ <style>
11
+ #boot-splash {
12
+ position: fixed;
13
+ inset: 0;
14
+ z-index: 9999;
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ justify-content: center;
19
+ gap: 16px;
20
+ background: #1a1b26;
21
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
22
+ transition: opacity 0.3s ease;
23
+ -webkit-app-region: drag; /* keep the frameless window draggable during load */
24
+ }
25
+ #boot-splash.boot-splash-hidden {
26
+ opacity: 0;
27
+ pointer-events: none;
28
+ }
29
+ .boot-spinner {
30
+ width: 34px;
31
+ height: 34px;
32
+ border-radius: 50%;
33
+ border: 3px solid rgba(122, 162, 247, 0.22);
34
+ border-top-color: #7aa2f7;
35
+ animation: boot-spin 0.8s linear infinite;
36
+ }
37
+ @keyframes boot-spin {
38
+ to { transform: rotate(360deg); }
39
+ }
40
+ .boot-splash-title {
41
+ font-size: 15px;
42
+ font-weight: 600;
43
+ letter-spacing: 0.02em;
44
+ color: #c0caf5;
45
+ }
46
+ .boot-splash-label {
47
+ font-size: 12px;
48
+ letter-spacing: 0.04em;
49
+ color: #565f89;
50
+ }
51
+ </style>
52
+ <script type="module" crossorigin src="./assets/index-Cj7K9-Ba.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-nvsNYP5c.css">
9
54
  </head>
10
55
  <body>
56
+ <div id="boot-splash">
57
+ <div class="boot-spinner"></div>
58
+ <div class="boot-splash-title">ctlsurf</div>
59
+ <div class="boot-splash-label">Starting up…</div>
60
+ </div>
11
61
  <div id="root"></div>
62
+ <script>
63
+ // Fades out and removes the boot splash. Called by the React app once its
64
+ // startup IPC has settled (see App.tsx). The timeout is a safety net so a
65
+ // hung startup never leaves the user stuck behind the splash forever.
66
+ window.__dismissSplash = function () {
67
+ var el = document.getElementById('boot-splash')
68
+ if (!el) return
69
+ el.classList.add('boot-splash-hidden')
70
+ setTimeout(function () { el.remove() }, 350)
71
+ }
72
+ setTimeout(function () { window.__dismissSplash() }, 10000)
73
+ </script>
12
74
  </body>
13
75
  </html>
@@ -0,0 +1,305 @@
1
+ // repro-toggle.ts
2
+ import fs2 from "fs";
3
+ import os2 from "os";
4
+ import path2 from "path";
5
+
6
+ // src/main/transcripts.ts
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ var POLL_INTERVAL_MS = 1e3;
11
+ var DISCOVERY_SLACK_MS = 1e4;
12
+ var READ_CHUNK_BYTES = 64 * 1024;
13
+ var MAX_ENTRY_CHARS = 2e4;
14
+ var TranscriptTailer = class {
15
+ agentId;
16
+ cwd;
17
+ sink;
18
+ claudeProjectsDir;
19
+ codexSessionsDir;
20
+ files = /* @__PURE__ */ new Map();
21
+ pollTimer = null;
22
+ sinceMs = 0;
23
+ constructor(options) {
24
+ this.agentId = options.agentId;
25
+ this.cwd = stripTrailingSep(options.cwd);
26
+ this.sink = options.sink;
27
+ this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), ".claude", "projects");
28
+ this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
29
+ }
30
+ start() {
31
+ if (this.pollTimer) return;
32
+ this.sinceMs = Date.now();
33
+ this.files.clear();
34
+ this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
35
+ console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`);
36
+ }
37
+ stop() {
38
+ if (!this.pollTimer) return;
39
+ clearInterval(this.pollTimer);
40
+ this.pollTimer = null;
41
+ this.poll();
42
+ this.files.clear();
43
+ console.log("[transcripts] Stopped");
44
+ }
45
+ poll() {
46
+ try {
47
+ this.discover();
48
+ for (const [filePath, tail] of this.files) {
49
+ if (!tail.excluded) this.drainFile(filePath, tail);
50
+ }
51
+ } catch (err) {
52
+ console.error("[transcripts] Poll error:", err);
53
+ }
54
+ }
55
+ // ─── Discovery ──────────────────────────────────
56
+ /**
57
+ * Track every transcript file with recent activity, not just the first
58
+ * match: /clear (Claude) or /new (Codex) starts a new session file in the
59
+ * middle of one PTY run, and tailing all active candidates handles the
60
+ * switch without special cases. Old idle files never match (stale mtime).
61
+ */
62
+ discover() {
63
+ const dirs = this.agentId === "claude" ? [this.claudeProjectsDirForCwd()] : this.codexDateDirs();
64
+ for (const dir of dirs) {
65
+ let names;
66
+ try {
67
+ names = fs.readdirSync(dir);
68
+ } catch {
69
+ continue;
70
+ }
71
+ for (const name of names) {
72
+ if (!name.endsWith(".jsonl")) continue;
73
+ if (this.agentId === "codex" && !name.startsWith("rollout-")) continue;
74
+ const filePath = path.join(dir, name);
75
+ if (this.files.has(filePath)) continue;
76
+ try {
77
+ const stat = fs.statSync(filePath);
78
+ if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
79
+ this.files.set(filePath, { offset: 0, remainder: "", excluded: false });
80
+ }
81
+ } catch {
82
+ }
83
+ }
84
+ }
85
+ }
86
+ claudeProjectsDirForCwd() {
87
+ const slug = this.cwd.replace(/[^a-zA-Z0-9]/g, "-");
88
+ return path.join(this.claudeProjectsDir, slug);
89
+ }
90
+ codexDateDirs() {
91
+ const dirs = /* @__PURE__ */ new Set();
92
+ for (const ms of [this.sinceMs, Date.now()]) {
93
+ const d = new Date(ms);
94
+ const yyyy = String(d.getFullYear());
95
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
96
+ const dd = String(d.getDate()).padStart(2, "0");
97
+ dirs.add(path.join(this.codexSessionsDir, yyyy, mm, dd));
98
+ }
99
+ return [...dirs];
100
+ }
101
+ // ─── Tailing ────────────────────────────────────
102
+ drainFile(filePath, tail) {
103
+ let size;
104
+ try {
105
+ size = fs.statSync(filePath).size;
106
+ } catch {
107
+ return;
108
+ }
109
+ if (size <= tail.offset) return;
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(filePath, "r");
113
+ } catch {
114
+ return;
115
+ }
116
+ try {
117
+ const buf = Buffer.alloc(READ_CHUNK_BYTES);
118
+ while (tail.offset < size && !tail.excluded) {
119
+ const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset);
120
+ if (bytesRead <= 0) break;
121
+ tail.offset += bytesRead;
122
+ tail.remainder += buf.toString("utf-8", 0, bytesRead);
123
+ const lines = tail.remainder.split("\n");
124
+ tail.remainder = lines.pop() || "";
125
+ for (const line2 of lines) {
126
+ this.handleLine(line2, tail);
127
+ if (tail.excluded) break;
128
+ }
129
+ }
130
+ } catch (err) {
131
+ console.error(`[transcripts] Read error for ${filePath}:`, err);
132
+ } finally {
133
+ try {
134
+ fs.closeSync(fd);
135
+ } catch {
136
+ }
137
+ }
138
+ }
139
+ handleLine(line2, tail) {
140
+ const trimmed = line2.trim();
141
+ if (!trimmed) return;
142
+ let obj;
143
+ try {
144
+ obj = JSON.parse(trimmed);
145
+ } catch {
146
+ return;
147
+ }
148
+ const entry = this.agentId === "claude" ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail);
149
+ if (!entry) return;
150
+ const ms = Date.parse(entry.ts);
151
+ if (Number.isFinite(ms) && ms < this.sinceMs - DISCOVERY_SLACK_MS) return;
152
+ this.sink({ ...entry, content: capLength(entry.content) });
153
+ }
154
+ // ─── Claude Code format ─────────────────────────
155
+ parseClaudeLine(obj) {
156
+ if (!obj || typeof obj !== "object") return null;
157
+ if (obj.isMeta) return null;
158
+ if (obj.type !== "user" && obj.type !== "assistant") return null;
159
+ if (typeof obj.cwd === "string" && stripTrailingSep(obj.cwd) !== this.cwd) return null;
160
+ if (obj.isSidechain) return null;
161
+ const message = obj.message;
162
+ if (!message) return null;
163
+ const text = extractClaudeText(message.content, obj.type === "user");
164
+ if (!text) return null;
165
+ return {
166
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
167
+ type: obj.type === "user" ? "user_input" : "terminal_output",
168
+ content: text
169
+ };
170
+ }
171
+ // ─── Codex CLI format ───────────────────────────
172
+ parseCodexLine(obj, tail) {
173
+ if (!obj || typeof obj !== "object") return null;
174
+ const payload = obj.payload;
175
+ if (obj.type === "session_meta") {
176
+ const metaCwd = payload?.cwd;
177
+ if (typeof metaCwd === "string" && stripTrailingSep(metaCwd) !== this.cwd) {
178
+ tail.excluded = true;
179
+ }
180
+ return null;
181
+ }
182
+ if (obj.type !== "event_msg" || !payload || typeof payload !== "object") return null;
183
+ let type;
184
+ if (payload.type === "user_message") {
185
+ type = "user_input";
186
+ } else if (payload.type === "agent_message") {
187
+ type = "terminal_output";
188
+ } else {
189
+ return null;
190
+ }
191
+ const text = typeof payload.message === "string" ? payload.message.trim() : "";
192
+ if (!text || isCodexNoise(text)) return null;
193
+ return {
194
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
195
+ type,
196
+ content: text
197
+ };
198
+ }
199
+ };
200
+ var CLAUDE_NOISE_PREFIXES = [
201
+ "<local-command-caveat>",
202
+ "<command-name>",
203
+ "<command-message>",
204
+ "<local-command-stdout>",
205
+ "<bash-input>",
206
+ "<bash-stdout>",
207
+ "<bash-stderr>",
208
+ "<system-reminder>",
209
+ "<task-notification>",
210
+ "caveat: the messages below",
211
+ "[request interrupted"
212
+ ];
213
+ var CODEX_NOISE_PREFIXES = [
214
+ "<environment_context>",
215
+ "<user_instructions>",
216
+ "<permissions instructions>",
217
+ "<turn_aborted>"
218
+ ];
219
+ function isClaudeNoise(text) {
220
+ const lower = text.trimStart().toLowerCase();
221
+ return CLAUDE_NOISE_PREFIXES.some((p) => lower.startsWith(p));
222
+ }
223
+ function isCodexNoise(text) {
224
+ const lower = text.trimStart().toLowerCase();
225
+ return CODEX_NOISE_PREFIXES.some((p) => lower.startsWith(p));
226
+ }
227
+ function extractClaudeText(content, isUser) {
228
+ if (typeof content === "string") {
229
+ const text = content.trim();
230
+ return text && !isClaudeNoise(text) ? text : "";
231
+ }
232
+ if (!Array.isArray(content)) return "";
233
+ if (isUser && content.some((b) => b?.type === "tool_result")) return "";
234
+ const parts = [];
235
+ for (const block of content) {
236
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
237
+ const text = block.text.trim();
238
+ if (text && !isClaudeNoise(text)) parts.push(text);
239
+ }
240
+ return parts.join("\n\n");
241
+ }
242
+ function stripTrailingSep(p) {
243
+ return p.length > 1 ? p.replace(/[/\\]+$/, "") : p;
244
+ }
245
+ function capLength(str) {
246
+ if (str.length <= MAX_ENTRY_CHARS) return str;
247
+ return str.slice(0, MAX_ENTRY_CHARS) + `\u2026 [truncated, ${str.length} total chars]`;
248
+ }
249
+
250
+ // repro-toggle.ts
251
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
252
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
253
+ var line = (o) => JSON.stringify(o) + "\n";
254
+ var tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), "toggle-test-"));
255
+ var claudeDir = path2.join(tmp, "claude-projects");
256
+ var cwd = "/Users/test/projects/my_app";
257
+ var projDir = path2.join(claudeDir, "-Users-test-projects-my-app");
258
+ fs2.mkdirSync(projDir, { recursive: true });
259
+ var sessionFile = path2.join(projDir, "session-1.jsonl");
260
+ var era = "enable#1";
261
+ var emitted = [];
262
+ var sink = (e) => emitted.push({ era, type: e.type, content: e.content });
263
+ function makeTailer() {
264
+ return new TranscriptTailer({ agentId: "claude", cwd, sink, claudeProjectsDir: claudeDir });
265
+ }
266
+ async function main() {
267
+ let tailer = makeTailer();
268
+ tailer.start();
269
+ await sleep(200);
270
+ fs2.appendFileSync(sessionFile, line({
271
+ type: "assistant",
272
+ cwd,
273
+ timestamp: now(),
274
+ message: { role: "assistant", content: [{ type: "text", text: "Reply ONE \u2014 while first enabled." }] }
275
+ }));
276
+ await sleep(1500);
277
+ tailer.stop();
278
+ era = "between";
279
+ await sleep(800);
280
+ era = "enable#2";
281
+ tailer = makeTailer();
282
+ tailer.start();
283
+ await sleep(200);
284
+ fs2.appendFileSync(sessionFile, line({
285
+ type: "assistant",
286
+ cwd,
287
+ timestamp: now(),
288
+ message: { role: "assistant", content: [{ type: "text", text: "Reply TWO \u2014 after off then on." }] }
289
+ }));
290
+ await sleep(1500);
291
+ tailer.stop();
292
+ console.log("\nEmitted entries:");
293
+ for (const e of emitted) console.log(` [${e.era}] ${e.type}: ${JSON.stringify(e.content)}`);
294
+ const two = emitted.filter((e) => e.content.includes("Reply TWO"));
295
+ console.log("\n--- VERDICT ---");
296
+ console.log(`Reply ONE emitted while enabled#1: ${emitted.some((e) => e.era === "enable#1" && e.content.includes("Reply ONE"))}`);
297
+ console.log(`Reply TWO emitted after off\u2192on: ${two.length > 0} (count=${two.length}, eras=${JSON.stringify(two.map((e) => e.era))})`);
298
+ if (two.length === 0) {
299
+ console.log(">> BUG REPRODUCED: new reply after toggle is NEVER forwarded by the tailer.");
300
+ } else {
301
+ console.log(">> Tailer re-emits fine \u2014 bug is NOT in the main-process layer.");
302
+ }
303
+ fs2.rmSync(tmp, { recursive: true, force: true });
304
+ }
305
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
package/src/main/index.ts CHANGED
@@ -106,6 +106,7 @@ const orchestrator = new Orchestrator(
106
106
  onWorkerMessage: (message) => mainWindow?.webContents.send('worker:message', message),
107
107
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
108
108
  onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
109
+ onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
109
110
  onCwdChanged: () => {
110
111
  mainWindow?.webContents.send('app:cwdChanged')
111
112
  updateProjectBadge(orchestrator.cwd)
@@ -414,6 +415,14 @@ ipcMain.handle('logchat:set', (_event, enabled: boolean) => {
414
415
  return { enabled: orchestrator.logChatEnabled }
415
416
  })
416
417
 
418
+ ipcMain.handle('speak:get', () => ({ enabled: orchestrator.speakRepliesEnabled }))
419
+ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
420
+ orchestrator.setSpeakReplies(!!enabled)
421
+ return { enabled: orchestrator.speakRepliesEnabled }
422
+ })
423
+ // Latest assistant reply from the active tab's transcript, for on-demand TTS.
424
+ ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
425
+
417
426
  // ─── Tracking IPC ─────────────────────────────────
418
427
 
419
428
  ipcMain.handle('tracking:get', () => ({ active: orchestrator.isActiveTabTracking() }))
@@ -6,7 +6,7 @@ import { PtyManager } from './pty'
6
6
  import { AgentConfig, isCodingAgent } from './agents'
7
7
  import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
- import { TranscriptTailer, supportsTranscriptLogging } from './transcripts'
9
+ import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
10
10
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
11
11
  import { TimeTracker } from './timeTracker'
12
12
  import { TicketStore, type TicketInput } from './ticketStore'
@@ -32,6 +32,9 @@ export interface SettingsData {
32
32
  ctlsurfBaseUrl?: string
33
33
  ctlsurfDataspacePageId?: string
34
34
  logChat?: boolean
35
+ // Speak agent replies aloud (Electron desktop only). Drives the transcript
36
+ // tailer the same way logChat does, independent of chat logging.
37
+ speakReplies?: boolean
35
38
  }
36
39
 
37
40
  export interface OrchestratorEvents {
@@ -44,6 +47,10 @@ export interface OrchestratorEvents {
44
47
  // Human-readable name of the connected ctlsurf project (folder), or null
45
48
  // when no project is connected. Optional — only the desktop header uses it.
46
49
  onProjectChanged?: (name: string | null) => void
50
+ // A clean assistant reply from the active agent's transcript, forwarded for
51
+ // text-to-speech. Optional and wired only by the Electron entry — the
52
+ // headless build leaves it unset, so replies are never spoken in a terminal.
53
+ onAgentMessage?: (text: string) => void
47
54
  }
48
55
 
49
56
  interface TabState {
@@ -148,16 +155,42 @@ export class Orchestrator {
148
155
  return !!this.settings.logChat
149
156
  }
150
157
 
158
+ get speakRepliesEnabled(): boolean {
159
+ return !!this.settings.speakReplies
160
+ }
161
+
162
+ // Latest assistant reply from a tab's transcript, for on-demand "read aloud".
163
+ // Defaults to the active tab. Returns null when the tab's agent has no native
164
+ // transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
165
+ // the transcript directly, so it works regardless of the speakReplies setting.
166
+ getLastReply(tabId?: string): string | null {
167
+ const id = tabId || this.activeTabId
168
+ if (!id) return null
169
+ const tab = this.tabs.get(id)
170
+ if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null
171
+ return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd })
172
+ }
173
+
151
174
  setLogChat(enabled: boolean): void {
152
175
  this.settings.logChat = enabled
153
176
  this.saveSettings()
154
177
  this.bridge.setLoggingEnabled(enabled)
155
- if (!enabled) {
156
- this.stopChatLogging()
157
- } else if (this.activeTabId) {
158
- const tab = this.tabs.get(this.activeTabId)
159
- if (tab) this.startChatLogging(tab.agent, tab.cwd)
160
- }
178
+ this.restartActiveChatLogging()
179
+ }
180
+
181
+ setSpeakReplies(enabled: boolean): void {
182
+ this.settings.speakReplies = enabled
183
+ this.saveSettings()
184
+ this.restartActiveChatLogging()
185
+ }
186
+
187
+ // Re-evaluate the transcript tailer for the active tab against the current
188
+ // logChat / speakReplies flags. Both setters funnel through here.
189
+ private restartActiveChatLogging(): void {
190
+ this.stopChatLogging()
191
+ if (!this.activeTabId) return
192
+ const tab = this.tabs.get(this.activeTabId)
193
+ if (tab) this.startChatLogging(tab.agent, tab.cwd)
161
194
  }
162
195
 
163
196
  // Agents with native session transcripts (Claude Code, Codex) get exact
@@ -165,16 +198,24 @@ export class Orchestrator {
165
198
  // the terminal screen-scraper bridge.
166
199
  private startChatLogging(agent: AgentConfig, cwd: string): void {
167
200
  this.stopChatLogging()
168
- if (!this.settings.logChat) return
201
+ if (!this.settings.logChat && !this.settings.speakReplies) return
169
202
 
170
203
  if (supportsTranscriptLogging(agent.id)) {
171
204
  this.transcriptTailer = new TranscriptTailer({
172
205
  agentId: agent.id,
173
206
  cwd,
174
- sink: (entry) => this.workerWs.sendChatLog(entry),
207
+ sink: (entry) => {
208
+ if (this.settings.logChat) this.workerWs.sendChatLog(entry)
209
+ // Only assistant replies are spoken; user_input entries are skipped.
210
+ if (this.settings.speakReplies && entry.type === 'terminal_output') {
211
+ this.events.onAgentMessage?.(entry.content)
212
+ }
213
+ },
175
214
  })
176
215
  this.transcriptTailer.start()
177
- } else {
216
+ } else if (this.settings.logChat) {
217
+ // The screen-scraper feeds ctlsurf chat logging only; TTS needs the clean
218
+ // transcript path, so non-transcript agents aren't spoken aloud.
178
219
  this.bridge.startSession()
179
220
  }
180
221
  }
@@ -339,3 +339,137 @@ function capLength(str: string): string {
339
339
  if (str.length <= MAX_ENTRY_CHARS) return str
340
340
  return str.slice(0, MAX_ENTRY_CHARS) + `… [truncated, ${str.length} total chars]`
341
341
  }
342
+
343
+ // ─── One-shot latest-reply read (on-demand "read aloud") ──────────
344
+ //
345
+ // Unlike the tailer, this does a single synchronous scan to return the most
346
+ // recent assistant reply for an agent + cwd, independent of whether the tailer
347
+ // is running. Used by the "read last response aloud" button so it works even
348
+ // when the speak-replies setting is off. Returns null when there's no
349
+ // transcript or no assistant message yet.
350
+ export function readLatestAssistantReply(opts: {
351
+ agentId: TranscriptAgentId
352
+ cwd: string
353
+ claudeProjectsDir?: string
354
+ codexSessionsDir?: string
355
+ }): string | null {
356
+ try {
357
+ const cwd = stripTrailingSep(opts.cwd)
358
+ if (opts.agentId === 'claude') {
359
+ const file = mostRecentClaudeFile(cwd, opts.claudeProjectsDir)
360
+ if (!file) return null
361
+ const text = lastClaudeAssistant(file, cwd)
362
+ return text ? capLength(text) : null
363
+ }
364
+ // Codex: the active session is the most recently written rollout for this cwd.
365
+ const codexRoot = opts.codexSessionsDir || path.join(os.homedir(), '.codex', 'sessions')
366
+ for (const file of recentCodexRollouts(codexRoot)) {
367
+ const text = lastCodexAgentMessage(file, cwd)
368
+ if (text) return capLength(text)
369
+ }
370
+ return null
371
+ } catch {
372
+ return null
373
+ }
374
+ }
375
+
376
+ // Whole-file read is fine here: transcripts are at most a few MB and we only do
377
+ // this on an explicit button click, not on a poll.
378
+ function readLines(file: string): string[] {
379
+ try {
380
+ return fs.readFileSync(file, 'utf-8').split('\n')
381
+ } catch {
382
+ return []
383
+ }
384
+ }
385
+
386
+ function mostRecentClaudeFile(cwd: string, root?: string): string | null {
387
+ const projectsRoot = root || path.join(os.homedir(), '.claude', 'projects')
388
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, '-')
389
+ const dir = path.join(projectsRoot, slug)
390
+ let names: string[]
391
+ try {
392
+ names = fs.readdirSync(dir)
393
+ } catch {
394
+ return null
395
+ }
396
+ let best: string | null = null
397
+ let bestMs = -Infinity
398
+ for (const name of names) {
399
+ if (!name.endsWith('.jsonl')) continue
400
+ const fp = path.join(dir, name)
401
+ try {
402
+ const ms = fs.statSync(fp).mtimeMs
403
+ if (ms > bestMs) { bestMs = ms; best = fp }
404
+ } catch { /* ignore */ }
405
+ }
406
+ return best
407
+ }
408
+
409
+ function recentCodexRollouts(root: string): string[] {
410
+ // Today and yesterday's session dirs (covers sessions spanning midnight),
411
+ // newest file first.
412
+ const dirs = new Set<string>()
413
+ const now = Date.now()
414
+ for (const ms of [now, now - 24 * 60 * 60 * 1000]) {
415
+ const d = new Date(ms)
416
+ const yyyy = String(d.getFullYear())
417
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
418
+ const dd = String(d.getDate()).padStart(2, '0')
419
+ dirs.add(path.join(root, yyyy, mm, dd))
420
+ }
421
+ const files: Array<{ fp: string; ms: number }> = []
422
+ for (const dir of dirs) {
423
+ let names: string[]
424
+ try { names = fs.readdirSync(dir) } catch { continue }
425
+ for (const name of names) {
426
+ if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue
427
+ const fp = path.join(dir, name)
428
+ try { files.push({ fp, ms: fs.statSync(fp).mtimeMs }) } catch { /* ignore */ }
429
+ }
430
+ }
431
+ return files.sort((a, b) => b.ms - a.ms).map((f) => f.fp)
432
+ }
433
+
434
+ function lastClaudeAssistant(file: string, cwd: string): string | null {
435
+ const lines = readLines(file)
436
+ for (let i = lines.length - 1; i >= 0; i--) {
437
+ const trimmed = lines[i].trim()
438
+ if (!trimmed) continue
439
+ let obj: any
440
+ try { obj = JSON.parse(trimmed) } catch { continue }
441
+ if (!obj || typeof obj !== 'object') continue
442
+ if (obj.isMeta || obj.isSidechain || obj.type !== 'assistant') continue
443
+ if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== cwd) continue
444
+ const text = extractClaudeText(obj.message?.content, false)
445
+ if (text) return text
446
+ }
447
+ return null
448
+ }
449
+
450
+ function lastCodexAgentMessage(file: string, cwd: string): string | null {
451
+ const lines = readLines(file)
452
+ // The date dir is shared across projects, so confirm this rollout belongs to
453
+ // our cwd via its session_meta before trusting any of its messages.
454
+ for (const line of lines) {
455
+ const trimmed = line.trim()
456
+ if (!trimmed) continue
457
+ let obj: any
458
+ try { obj = JSON.parse(trimmed) } catch { continue }
459
+ if (obj?.type === 'session_meta') {
460
+ const metaCwd = obj.payload?.cwd
461
+ if (typeof metaCwd === 'string' && stripTrailingSep(metaCwd) !== cwd) return null
462
+ break
463
+ }
464
+ }
465
+ for (let i = lines.length - 1; i >= 0; i--) {
466
+ const trimmed = lines[i].trim()
467
+ if (!trimmed) continue
468
+ let obj: any
469
+ try { obj = JSON.parse(trimmed) } catch { continue }
470
+ if (obj?.type !== 'event_msg' || obj.payload?.type !== 'agent_message') continue
471
+ const text = typeof obj.payload.message === 'string' ? obj.payload.message.trim() : ''
472
+ if (text && !isCodexNoise(text)) return text
473
+ }
474
+ return null
475
+ }