@phenx-inc/ctlsurf 0.8.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 +144 -1
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +144 -0
- package/out/preload/index.js +2 -0
- package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BdYp0fp9.js} +3 -3
- package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-Cya-9zr-.js} +1 -1
- package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-EJs9W5Ub.js} +1 -1
- package/out/renderer/assets/{html-D8xFiRmI.js → html-Ex4qy2lh.js} +1 -1
- package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-CCm0aJ6C.js} +3 -3
- package/out/renderer/assets/{index---H6cxNl.js → index-Cj7K9-Ba.js} +265 -242
- package/out/renderer/assets/{index-B-iM7dFC.css → index-nvsNYP5c.css} +34 -0
- package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-B2niCouI.js} +2 -2
- package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BgCUHaFo.js} +3 -3
- package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-DOQmSo4G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
- package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-DuKdGNKE.js} +1 -1
- package/out/renderer/assets/{python-DKu7rNbs.js → python-BUa4XFG-.js} +1 -1
- package/out/renderer/assets/{razor-BOMpCo6z.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-yAjlPR-D.js → tsMode-8FLRnobw.js} +1 -1
- package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-rdwAjCi4.js} +1 -1
- package/out/renderer/assets/{xml-D4PvYeQq.js → xml-HOzT59Qg.js} +1 -1
- package/out/renderer/assets/{yaml-BeHVkmnS.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 +2 -0
- package/src/main/orchestrator.ts +13 -1
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +3 -0
- package/src/renderer/App.tsx +25 -11
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/VoiceInput.tsx +36 -149
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +46 -2
- package/src/renderer/lib/speech.ts +18 -0
- package/src/renderer/styles.css +34 -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
|
@@ -420,6 +420,8 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
|
|
|
420
420
|
orchestrator.setSpeakReplies(!!enabled)
|
|
421
421
|
return { enabled: orchestrator.speakRepliesEnabled }
|
|
422
422
|
})
|
|
423
|
+
// Latest assistant reply from the active tab's transcript, for on-demand TTS.
|
|
424
|
+
ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
|
|
423
425
|
|
|
424
426
|
// ─── Tracking IPC ─────────────────────────────────
|
|
425
427
|
|
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'
|
|
@@ -159,6 +159,18 @@ export class Orchestrator {
|
|
|
159
159
|
return !!this.settings.speakReplies
|
|
160
160
|
}
|
|
161
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
|
+
|
|
162
174
|
setLogChat(enabled: boolean): void {
|
|
163
175
|
this.settings.logChat = enabled
|
|
164
176
|
this.saveSettings()
|
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
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -119,6 +119,9 @@ const api = {
|
|
|
119
119
|
ipcRenderer.on('agent:message', listener)
|
|
120
120
|
return () => ipcRenderer.removeListener('agent:message', listener)
|
|
121
121
|
},
|
|
122
|
+
// Latest assistant reply from the active tab's transcript (null if none).
|
|
123
|
+
getLastReply: (): Promise<string | null> =>
|
|
124
|
+
ipcRenderer.invoke('agent:getLastReply'),
|
|
122
125
|
|
|
123
126
|
// Filesystem
|
|
124
127
|
readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
|
package/src/renderer/App.tsx
CHANGED
|
@@ -60,6 +60,7 @@ declare global {
|
|
|
60
60
|
getSpeakReplies: () => Promise<{ enabled: boolean }>
|
|
61
61
|
setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
62
62
|
onAgentMessage: (callback: (text: string) => void) => () => void
|
|
63
|
+
getLastReply: () => Promise<string | null>
|
|
63
64
|
createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
|
|
64
65
|
getWebviewInfo: () => Promise<{
|
|
65
66
|
frontendUrl: string; pageUrl?: string; authenticated: boolean;
|
|
@@ -155,16 +156,22 @@ export default function App() {
|
|
|
155
156
|
|
|
156
157
|
useEffect(() => {
|
|
157
158
|
async function init() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
try {
|
|
160
|
+
const list = await window.worker.listAgents()
|
|
161
|
+
setAgents(list)
|
|
162
|
+
const status = await window.worker.getWorkerStatus()
|
|
163
|
+
setWsStatus(status)
|
|
164
|
+
// Load initial cwd for the picker
|
|
165
|
+
const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
|
|
166
|
+
setCwd(initialCwd)
|
|
167
|
+
cwdRef.current = initialCwd
|
|
168
|
+
// Connected ctlsurf project name for the header (null until a worker registers)
|
|
169
|
+
window.worker.getProjectName().then(setProjectName).catch(() => { /* ignore */ })
|
|
170
|
+
} finally {
|
|
171
|
+
// Dismiss the boot splash painted by index.html now that startup IPC has
|
|
172
|
+
// settled. In `finally` so a failed IPC call never leaves it stuck on.
|
|
173
|
+
;(window as unknown as { __dismissSplash?: () => void }).__dismissSplash?.()
|
|
174
|
+
}
|
|
168
175
|
}
|
|
169
176
|
init()
|
|
170
177
|
}, [])
|
|
@@ -225,8 +232,15 @@ export default function App() {
|
|
|
225
232
|
const handleVoiceTranscript = useCallback((text: string) => {
|
|
226
233
|
const trimmed = text.trim()
|
|
227
234
|
if (!trimmed) return
|
|
228
|
-
|
|
235
|
+
// Send the text and the submit Enter as TWO separate writes. A long transcript
|
|
236
|
+
// glued to its trailing \r arrives as one fast burst, which the agent's input
|
|
237
|
+
// box treats as a paste and turns the carriage return into a literal newline
|
|
238
|
+
// instead of submitting. Delaying the Enter lets the text be consumed first so
|
|
239
|
+
// \r registers as a real submit keystroke. (80ms is the tunable knob if very
|
|
240
|
+
// long input ever needs more time to drain.)
|
|
241
|
+
window.worker.writePty(activeTabId, trimmed)
|
|
229
242
|
focusTerminal(activeTabId)
|
|
243
|
+
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
|
|
230
244
|
}, [activeTabId])
|
|
231
245
|
|
|
232
246
|
const cwdRef = useRef<string | null>(null)
|