@phenx-inc/ctlsurf 0.8.0 → 0.10.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 +339 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +441 -20
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
  10. package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
  11. package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.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/agentState.ts +329 -0
  28. package/src/main/index.ts +107 -19
  29. package/src/main/orchestrator.ts +34 -2
  30. package/src/main/transcripts.ts +134 -0
  31. package/src/preload/index.ts +11 -0
  32. package/src/renderer/App.tsx +48 -13
  33. package/src/renderer/components/FloatingMic.tsx +48 -0
  34. package/src/renderer/components/VoiceInput.tsx +36 -149
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +46 -2
  37. package/src/renderer/lib/speech.ts +18 -0
  38. package/src/renderer/styles.css +52 -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---H6cxNl.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-Dg1vMspf.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---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.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---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.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---H6cxNl.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-B-iM7dFC.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-Dg1vMspf.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-BIceGcz3.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.8.0",
3
+ "version": "0.10.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": {