@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.
- package/out/headless/index.mjs +169 -10
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +175 -9
- package/out/preload/index.js +10 -0
- package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
- package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
- package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
- package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
- package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
- package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
- package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
- package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
- package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
- package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
- package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
- package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
- package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
- package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
- package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
- package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
- package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
- package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.js} +1 -1
- package/out/renderer/index.html +64 -2
- package/out/repro-toggle.mjs +305 -0
- package/package.json +1 -1
- package/src/main/index.ts +9 -0
- package/src/main/orchestrator.ts +51 -10
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +14 -0
- package/src/renderer/App.tsx +30 -11
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/SpeakControls.tsx +235 -0
- package/src/renderer/components/VoiceInput.tsx +183 -140
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +94 -6
- package/src/renderer/lib/speech.ts +317 -0
- 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-
|
|
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;
|
package/out/renderer/index.html
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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.
|
|
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() }))
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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) =>
|
|
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
|
}
|
package/src/main/transcripts.ts
CHANGED
|
@@ -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
|
+
}
|