@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
@@ -5471,7 +5471,7 @@ var require_package = __commonJS({
5471
5471
  "package.json"(exports, module) {
5472
5472
  module.exports = {
5473
5473
  name: "@phenx-inc/ctlsurf",
5474
- version: "0.8.0",
5474
+ version: "0.10.0",
5475
5475
  description: "Agent-agnostic terminal and desktop app for ctlsurf \u2014 run Claude Code, Codex, or any coding agent with live session logging and remote control",
5476
5476
  main: "out/main/index.js",
5477
5477
  bin: {
@@ -6337,6 +6337,320 @@ function capLength(str) {
6337
6337
  if (str.length <= MAX_ENTRY_CHARS) return str;
6338
6338
  return str.slice(0, MAX_ENTRY_CHARS) + `\u2026 [truncated, ${str.length} total chars]`;
6339
6339
  }
6340
+ function readLatestAssistantReply(opts) {
6341
+ try {
6342
+ const cwd = stripTrailingSep(opts.cwd);
6343
+ if (opts.agentId === "claude") {
6344
+ const file = mostRecentClaudeFile(cwd, opts.claudeProjectsDir);
6345
+ if (!file) return null;
6346
+ const text = lastClaudeAssistant(file, cwd);
6347
+ return text ? capLength(text) : null;
6348
+ }
6349
+ const codexRoot = opts.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
6350
+ for (const file of recentCodexRollouts(codexRoot)) {
6351
+ const text = lastCodexAgentMessage(file, cwd);
6352
+ if (text) return capLength(text);
6353
+ }
6354
+ return null;
6355
+ } catch {
6356
+ return null;
6357
+ }
6358
+ }
6359
+ function readLines(file) {
6360
+ try {
6361
+ return fs.readFileSync(file, "utf-8").split("\n");
6362
+ } catch {
6363
+ return [];
6364
+ }
6365
+ }
6366
+ function mostRecentClaudeFile(cwd, root) {
6367
+ const projectsRoot = root || path.join(os.homedir(), ".claude", "projects");
6368
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
6369
+ const dir = path.join(projectsRoot, slug);
6370
+ let names;
6371
+ try {
6372
+ names = fs.readdirSync(dir);
6373
+ } catch {
6374
+ return null;
6375
+ }
6376
+ let best = null;
6377
+ let bestMs = -Infinity;
6378
+ for (const name of names) {
6379
+ if (!name.endsWith(".jsonl")) continue;
6380
+ const fp = path.join(dir, name);
6381
+ try {
6382
+ const ms = fs.statSync(fp).mtimeMs;
6383
+ if (ms > bestMs) {
6384
+ bestMs = ms;
6385
+ best = fp;
6386
+ }
6387
+ } catch {
6388
+ }
6389
+ }
6390
+ return best;
6391
+ }
6392
+ function recentCodexRollouts(root) {
6393
+ const dirs = /* @__PURE__ */ new Set();
6394
+ const now = Date.now();
6395
+ for (const ms of [now, now - 24 * 60 * 60 * 1e3]) {
6396
+ const d = new Date(ms);
6397
+ const yyyy = String(d.getFullYear());
6398
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
6399
+ const dd = String(d.getDate()).padStart(2, "0");
6400
+ dirs.add(path.join(root, yyyy, mm, dd));
6401
+ }
6402
+ const files = [];
6403
+ for (const dir of dirs) {
6404
+ let names;
6405
+ try {
6406
+ names = fs.readdirSync(dir);
6407
+ } catch {
6408
+ continue;
6409
+ }
6410
+ for (const name of names) {
6411
+ if (!name.startsWith("rollout-") || !name.endsWith(".jsonl")) continue;
6412
+ const fp = path.join(dir, name);
6413
+ try {
6414
+ files.push({ fp, ms: fs.statSync(fp).mtimeMs });
6415
+ } catch {
6416
+ }
6417
+ }
6418
+ }
6419
+ return files.sort((a, b) => b.ms - a.ms).map((f) => f.fp);
6420
+ }
6421
+ function lastClaudeAssistant(file, cwd) {
6422
+ const lines = readLines(file);
6423
+ for (let i = lines.length - 1; i >= 0; i--) {
6424
+ const trimmed = lines[i].trim();
6425
+ if (!trimmed) continue;
6426
+ let obj;
6427
+ try {
6428
+ obj = JSON.parse(trimmed);
6429
+ } catch {
6430
+ continue;
6431
+ }
6432
+ if (!obj || typeof obj !== "object") continue;
6433
+ if (obj.isMeta || obj.isSidechain || obj.type !== "assistant") continue;
6434
+ if (typeof obj.cwd === "string" && stripTrailingSep(obj.cwd) !== cwd) continue;
6435
+ const text = extractClaudeText(obj.message?.content, false);
6436
+ if (text) return text;
6437
+ }
6438
+ return null;
6439
+ }
6440
+ function lastCodexAgentMessage(file, cwd) {
6441
+ const lines = readLines(file);
6442
+ for (const line of lines) {
6443
+ const trimmed = line.trim();
6444
+ if (!trimmed) continue;
6445
+ let obj;
6446
+ try {
6447
+ obj = JSON.parse(trimmed);
6448
+ } catch {
6449
+ continue;
6450
+ }
6451
+ if (obj?.type === "session_meta") {
6452
+ const metaCwd = obj.payload?.cwd;
6453
+ if (typeof metaCwd === "string" && stripTrailingSep(metaCwd) !== cwd) return null;
6454
+ break;
6455
+ }
6456
+ }
6457
+ for (let i = lines.length - 1; i >= 0; i--) {
6458
+ const trimmed = lines[i].trim();
6459
+ if (!trimmed) continue;
6460
+ let obj;
6461
+ try {
6462
+ obj = JSON.parse(trimmed);
6463
+ } catch {
6464
+ continue;
6465
+ }
6466
+ if (obj?.type !== "event_msg" || obj.payload?.type !== "agent_message") continue;
6467
+ const text = typeof obj.payload.message === "string" ? obj.payload.message.trim() : "";
6468
+ if (text && !isCodexNoise(text)) return text;
6469
+ }
6470
+ return null;
6471
+ }
6472
+
6473
+ // src/main/agentState.ts
6474
+ var BUSY_GRACE_MS = 2500;
6475
+ var SETTLE_MS = 1200;
6476
+ var DECISION_TTL_MS = 4e3;
6477
+ var TICK_MS = 400;
6478
+ var HOOK_AUTHORITY_MS = 6e3;
6479
+ var TAIL_KEEP_CHARS = 240;
6480
+ var CLAUDE_SPINNER = /[✶✻✳✺✢✷✸✹❋][^\n]{0,60}(?:…|\.\.\.)/;
6481
+ function hasBusyMarker(text) {
6482
+ return /esc to interrupt/i.test(text) || /\bWorking \(\d+s/i.test(text) || CLAUDE_SPINNER.test(text);
6483
+ }
6484
+ var APPROVAL_PATTERNS = [
6485
+ /\bDo you want to (proceed|continue|create|make this edit|run)\b/i,
6486
+ // Claude Code
6487
+ /❯\s*\d+\.\s*Yes\b/i,
6488
+ // Claude numbered-choice prompt
6489
+ /\bAllow command\b/i,
6490
+ // Codex
6491
+ /\bRun this command\?/i,
6492
+ // Codex
6493
+ /\bApprove\b[^\n]*\?/i,
6494
+ /\(y\/n\)/i,
6495
+ /\[y\/N\]/i
6496
+ ];
6497
+ function hasApprovalMarker(text) {
6498
+ return APPROVAL_PATTERNS.some((re) => re.test(text));
6499
+ }
6500
+ function stripAnsi2(str) {
6501
+ return str.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b/g, "");
6502
+ }
6503
+ var AgentStateMonitor = class {
6504
+ sink;
6505
+ now;
6506
+ tabs = /* @__PURE__ */ new Map();
6507
+ timer = null;
6508
+ lastAnyAwaiting = false;
6509
+ constructor(sink, now = Date.now) {
6510
+ this.sink = sink;
6511
+ this.now = now;
6512
+ }
6513
+ start() {
6514
+ if (this.timer) return;
6515
+ this.timer = setInterval(() => this.tick(), TICK_MS);
6516
+ }
6517
+ stop() {
6518
+ if (this.timer) {
6519
+ clearInterval(this.timer);
6520
+ this.timer = null;
6521
+ }
6522
+ this.tabs.clear();
6523
+ this.lastAnyAwaiting = false;
6524
+ }
6525
+ /** Register a tab so its raw PTY output can be classified. */
6526
+ trackTab(tabId, agentId, cwd) {
6527
+ const now = this.now();
6528
+ this.tabs.set(tabId, {
6529
+ agentId,
6530
+ cwd,
6531
+ state: "idle",
6532
+ everBusy: false,
6533
+ lastBusyAt: 0,
6534
+ lastOutputAt: now,
6535
+ lastDecisionAt: 0,
6536
+ hookState: null,
6537
+ hookAt: 0,
6538
+ tail: ""
6539
+ });
6540
+ }
6541
+ removeTab(tabId) {
6542
+ if (this.tabs.delete(tabId)) this.emitAggregate();
6543
+ }
6544
+ /** Feed a raw PTY output chunk for a tab. */
6545
+ feed(tabId, data) {
6546
+ const rec = this.tabs.get(tabId);
6547
+ if (!rec) return;
6548
+ const now = this.now();
6549
+ rec.lastOutputAt = now;
6550
+ const text = rec.tail + stripAnsi2(data);
6551
+ rec.tail = text.length > TAIL_KEEP_CHARS ? text.slice(-TAIL_KEEP_CHARS) : text;
6552
+ if (hasBusyMarker(text)) {
6553
+ rec.lastBusyAt = now;
6554
+ rec.everBusy = true;
6555
+ }
6556
+ if (hasApprovalMarker(text)) {
6557
+ rec.lastDecisionAt = now;
6558
+ }
6559
+ }
6560
+ /**
6561
+ * Note input written to a tab's PTY. Only a *submit* (data containing a
6562
+ * newline) counts as the user engaging — clearing any awaiting state and
6563
+ * resetting the "has worked" flag so a leftover idle box doesn't re-flag.
6564
+ *
6565
+ * Individual keystrokes and, crucially, the terminal's own auto-responses
6566
+ * (cursor-position / device-attribute replies that TUIs constantly trigger,
6567
+ * which xterm.js writes straight back to the PTY) are ignored — otherwise
6568
+ * the state would churn busy↔idle on every control-sequence handshake.
6569
+ */
6570
+ noteInput(tabId, data) {
6571
+ if (!/[\r\n]/.test(data)) return;
6572
+ const rec = this.tabs.get(tabId);
6573
+ if (!rec) return;
6574
+ const now = this.now();
6575
+ rec.everBusy = false;
6576
+ rec.lastBusyAt = now;
6577
+ rec.lastDecisionAt = 0;
6578
+ rec.hookState = null;
6579
+ this.setState(rec, tabId, "busy");
6580
+ }
6581
+ /**
6582
+ * Apply an authoritative state from an agent hook, addressed by cwd. Overrides
6583
+ * the heuristic for HOOK_AUTHORITY_MS. `kind`:
6584
+ * 'awaiting_input' — turn done / idle prompt (Stop, idle_prompt, agent-turn-complete)
6585
+ * 'awaiting_decision' — permission/approval prompt (PermissionRequest, permission_prompt)
6586
+ * 'busy' — user submitted a prompt (UserPromptSubmit)
6587
+ */
6588
+ applyHookEvent(cwd, kind) {
6589
+ const target = stripTrailingSep2(cwd);
6590
+ const now = this.now();
6591
+ for (const [tabId, rec] of this.tabs) {
6592
+ if (stripTrailingSep2(rec.cwd) !== target) continue;
6593
+ rec.hookState = kind;
6594
+ rec.hookAt = now;
6595
+ if (kind === "busy") {
6596
+ rec.everBusy = false;
6597
+ rec.lastBusyAt = now;
6598
+ }
6599
+ this.setState(rec, tabId, kind);
6600
+ }
6601
+ }
6602
+ tick() {
6603
+ const now = this.now();
6604
+ for (const [tabId, rec] of this.tabs) {
6605
+ if (rec.hookState && now - rec.hookAt < HOOK_AUTHORITY_MS) {
6606
+ this.setState(rec, tabId, rec.hookState);
6607
+ continue;
6608
+ }
6609
+ const busyRecently = rec.lastBusyAt > 0 && now - rec.lastBusyAt < BUSY_GRACE_MS;
6610
+ const settled = now - rec.lastOutputAt >= SETTLE_MS;
6611
+ const decisionRecent = rec.lastDecisionAt > 0 && now - rec.lastDecisionAt < DECISION_TTL_MS;
6612
+ let next = null;
6613
+ if (busyRecently) {
6614
+ next = "busy";
6615
+ } else if (settled && rec.everBusy) {
6616
+ next = rec.state === "awaiting_decision" || decisionRecent ? "awaiting_decision" : "awaiting_input";
6617
+ } else if (settled && !rec.everBusy) {
6618
+ next = "idle";
6619
+ }
6620
+ if (next) this.setState(rec, tabId, next);
6621
+ }
6622
+ this.emitAggregate();
6623
+ }
6624
+ setState(rec, tabId, state) {
6625
+ if (rec.state === state) return;
6626
+ rec.state = state;
6627
+ this.sink({ tabId, state, anyAwaiting: this.computeAnyAwaiting() });
6628
+ this.lastAnyAwaiting = this.computeAnyAwaiting();
6629
+ }
6630
+ emitAggregate() {
6631
+ const anyAwaiting = this.computeAnyAwaiting();
6632
+ if (anyAwaiting === this.lastAnyAwaiting) return;
6633
+ this.lastAnyAwaiting = anyAwaiting;
6634
+ const tabId = this.firstAwaitingTabId() ?? "";
6635
+ const state = tabId ? this.tabs.get(tabId).state : "idle";
6636
+ this.sink({ tabId, state, anyAwaiting });
6637
+ }
6638
+ computeAnyAwaiting() {
6639
+ for (const rec of this.tabs.values()) {
6640
+ if (rec.state === "awaiting_input" || rec.state === "awaiting_decision") return true;
6641
+ }
6642
+ return false;
6643
+ }
6644
+ firstAwaitingTabId() {
6645
+ for (const [tabId, rec] of this.tabs) {
6646
+ if (rec.state === "awaiting_input" || rec.state === "awaiting_decision") return tabId;
6647
+ }
6648
+ return null;
6649
+ }
6650
+ };
6651
+ function stripTrailingSep2(p) {
6652
+ return p.length > 1 ? p.replace(/[/\\]+$/, "") : p;
6653
+ }
6340
6654
 
6341
6655
  // src/main/workerWs.ts
6342
6656
  import os2 from "os";
@@ -7133,9 +7447,14 @@ var Orchestrator = class {
7133
7447
  noProjectPollCwd = null;
7134
7448
  currentProjectName = null;
7135
7449
  transcriptTailer = null;
7450
+ stateMonitor;
7136
7451
  constructor(settingsDir, events) {
7137
7452
  this.settingsDir = settingsDir;
7138
7453
  this.events = events;
7454
+ this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
7455
+ this.events.onAgentStateChange?.(tabId, state, anyAwaiting);
7456
+ });
7457
+ this.stateMonitor.start();
7139
7458
  this.workerWs = new WorkerWsClient({
7140
7459
  onStatusChange: (status) => {
7141
7460
  log(`[worker-ws] Status: ${status}`);
@@ -7147,9 +7466,10 @@ var Orchestrator = class {
7147
7466
  this.workerWs.sendAck(message.id);
7148
7467
  if (message.type === "prompt" || message.type === "task_dispatch") {
7149
7468
  const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
7150
- if (activeTab) {
7469
+ if (activeTab && this.activeTabId) {
7151
7470
  activeTab.ptyManager.write(message.content + "\r");
7152
7471
  this.bridge.feedInput(message.content + "\r");
7472
+ this.stateMonitor.noteInput(this.activeTabId, message.content + "\r");
7153
7473
  }
7154
7474
  }
7155
7475
  },
@@ -7182,6 +7502,17 @@ var Orchestrator = class {
7182
7502
  get speakRepliesEnabled() {
7183
7503
  return !!this.settings.speakReplies;
7184
7504
  }
7505
+ // Latest assistant reply from a tab's transcript, for on-demand "read aloud".
7506
+ // Defaults to the active tab. Returns null when the tab's agent has no native
7507
+ // transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
7508
+ // the transcript directly, so it works regardless of the speakReplies setting.
7509
+ getLastReply(tabId) {
7510
+ const id = tabId || this.activeTabId;
7511
+ if (!id) return null;
7512
+ const tab = this.tabs.get(id);
7513
+ if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null;
7514
+ return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd });
7515
+ }
7185
7516
  setLogChat(enabled) {
7186
7517
  this.settings.logChat = enabled;
7187
7518
  this.saveSettings();
@@ -7429,9 +7760,11 @@ var Orchestrator = class {
7429
7760
  const ptyManager = new PtyManager(agent, cwd);
7430
7761
  const tab = { ptyManager, agent, cwd, termStreamBuffer: "", termStreamTimer: null };
7431
7762
  this.tabs.set(tabId, tab);
7763
+ this.stateMonitor.trackTab(tabId, agent.id, cwd);
7432
7764
  ptyManager.onData((data) => {
7433
7765
  this.events.onPtyData(tabId, data);
7434
7766
  this.timeTracker.recordActivity(tabId);
7767
+ this.stateMonitor.feed(tabId, data);
7435
7768
  if (tabId === this.activeTabId) {
7436
7769
  this.bridge.feedOutput(data);
7437
7770
  this.streamTerminalData(tabId, data);
@@ -7448,6 +7781,7 @@ var Orchestrator = class {
7448
7781
  }
7449
7782
  const t = this.tabs.get(tabId);
7450
7783
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer);
7784
+ this.stateMonitor.removeTab(tabId);
7451
7785
  });
7452
7786
  this.startChatLogging(agent, cwd);
7453
7787
  const profile = this.getActiveProfile();
@@ -7470,6 +7804,7 @@ var Orchestrator = class {
7470
7804
  }
7471
7805
  writePty(tabId, data) {
7472
7806
  this.tabs.get(tabId)?.ptyManager.write(data);
7807
+ this.stateMonitor.noteInput(tabId, data);
7473
7808
  if (tabId === this.activeTabId) {
7474
7809
  this.bridge.feedInput(data);
7475
7810
  }
@@ -7488,6 +7823,7 @@ var Orchestrator = class {
7488
7823
  await this.timeTracker.endSession(tabId);
7489
7824
  tab.ptyManager.kill();
7490
7825
  this.tabs.delete(tabId);
7826
+ this.stateMonitor.removeTab(tabId);
7491
7827
  if (tabId === this.activeTabId) {
7492
7828
  this.stopChatLogging();
7493
7829
  if (isCodingAgent(tab.agent)) {
@@ -7641,6 +7977,7 @@ var Orchestrator = class {
7641
7977
  async shutdown() {
7642
7978
  this.stopNoProjectPolling();
7643
7979
  this.stopChatLogging();
7980
+ this.stateMonitor.stop();
7644
7981
  await this.timeTracker.endAll();
7645
7982
  for (const [, tab] of this.tabs) {
7646
7983
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);