@phenx-inc/ctlsurf 0.4.0 → 0.5.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.
@@ -5445,19 +5445,19 @@ var require_addon_serialize = __commonJS({
5445
5445
  // node_modules/electron/index.js
5446
5446
  var require_electron = __commonJS({
5447
5447
  "node_modules/electron/index.js"(exports, module) {
5448
- var fs2 = __require("fs");
5449
- var path3 = __require("path");
5450
- var pathFile = path3.join(__dirname, "path.txt");
5448
+ var fs3 = __require("fs");
5449
+ var path4 = __require("path");
5450
+ var pathFile = path4.join(__dirname, "path.txt");
5451
5451
  function getElectronPath() {
5452
5452
  let executablePath;
5453
- if (fs2.existsSync(pathFile)) {
5454
- executablePath = fs2.readFileSync(pathFile, "utf-8");
5453
+ if (fs3.existsSync(pathFile)) {
5454
+ executablePath = fs3.readFileSync(pathFile, "utf-8");
5455
5455
  }
5456
5456
  if (process.env.ELECTRON_OVERRIDE_DIST_PATH) {
5457
- return path3.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, executablePath || "electron");
5457
+ return path4.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, executablePath || "electron");
5458
5458
  }
5459
5459
  if (executablePath) {
5460
- return path3.join(__dirname, "dist", executablePath);
5460
+ return path4.join(__dirname, "dist", executablePath);
5461
5461
  } else {
5462
5462
  throw new Error("Electron failed to install correctly, please delete node_modules/electron and try installing again");
5463
5463
  }
@@ -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.4.0",
5474
+ version: "0.5.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: {
@@ -5562,9 +5562,9 @@ function log(...args) {
5562
5562
  }
5563
5563
 
5564
5564
  // src/main/orchestrator.ts
5565
- import path from "path";
5566
- import fs from "fs";
5567
- import os3 from "os";
5565
+ import path2 from "path";
5566
+ import fs2 from "fs";
5567
+ import os4 from "os";
5568
5568
 
5569
5569
  // src/main/pty.ts
5570
5570
  import { createRequire } from "module";
@@ -5700,8 +5700,8 @@ var CtlsurfApi = class {
5700
5700
  }
5701
5701
  return h;
5702
5702
  }
5703
- async request(method, path3, body) {
5704
- const url = `${this.baseUrl}${path3}`;
5703
+ async request(method, path4, body) {
5704
+ const url = `${this.baseUrl}${path4}`;
5705
5705
  const opts = {
5706
5706
  method,
5707
5707
  headers: this.headers()
@@ -5712,7 +5712,7 @@ var CtlsurfApi = class {
5712
5712
  const res = await fetch(url, opts);
5713
5713
  if (!res.ok) {
5714
5714
  const text = await res.text();
5715
- throw new Error(`ctlsurf API ${method} ${path3}: ${res.status} ${text}`);
5715
+ throw new Error(`ctlsurf API ${method} ${path4}: ${res.status} ${text}`);
5716
5716
  }
5717
5717
  return res.json();
5718
5718
  }
@@ -6090,8 +6090,255 @@ function trimToLogLimit(str) {
6090
6090
  ${str.slice(str.length - MAX_LOG_CHARS)}`;
6091
6091
  }
6092
6092
 
6093
- // src/main/workerWs.ts
6093
+ // src/main/transcripts.ts
6094
+ import fs from "fs";
6095
+ import path from "path";
6094
6096
  import os from "os";
6097
+ function supportsTranscriptLogging(agentId) {
6098
+ return agentId === "claude" || agentId === "codex";
6099
+ }
6100
+ var POLL_INTERVAL_MS = 1e3;
6101
+ var DISCOVERY_SLACK_MS = 1e4;
6102
+ var READ_CHUNK_BYTES = 64 * 1024;
6103
+ var MAX_ENTRY_CHARS = 2e4;
6104
+ var TranscriptTailer = class {
6105
+ agentId;
6106
+ cwd;
6107
+ sink;
6108
+ claudeProjectsDir;
6109
+ codexSessionsDir;
6110
+ files = /* @__PURE__ */ new Map();
6111
+ pollTimer = null;
6112
+ sinceMs = 0;
6113
+ constructor(options) {
6114
+ this.agentId = options.agentId;
6115
+ this.cwd = stripTrailingSep(options.cwd);
6116
+ this.sink = options.sink;
6117
+ this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), ".claude", "projects");
6118
+ this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
6119
+ }
6120
+ start() {
6121
+ if (this.pollTimer) return;
6122
+ this.sinceMs = Date.now();
6123
+ this.files.clear();
6124
+ this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
6125
+ console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`);
6126
+ }
6127
+ stop() {
6128
+ if (!this.pollTimer) return;
6129
+ clearInterval(this.pollTimer);
6130
+ this.pollTimer = null;
6131
+ this.poll();
6132
+ this.files.clear();
6133
+ console.log("[transcripts] Stopped");
6134
+ }
6135
+ poll() {
6136
+ try {
6137
+ this.discover();
6138
+ for (const [filePath, tail] of this.files) {
6139
+ if (!tail.excluded) this.drainFile(filePath, tail);
6140
+ }
6141
+ } catch (err) {
6142
+ console.error("[transcripts] Poll error:", err);
6143
+ }
6144
+ }
6145
+ // ─── Discovery ──────────────────────────────────
6146
+ /**
6147
+ * Track every transcript file with recent activity, not just the first
6148
+ * match: /clear (Claude) or /new (Codex) starts a new session file in the
6149
+ * middle of one PTY run, and tailing all active candidates handles the
6150
+ * switch without special cases. Old idle files never match (stale mtime).
6151
+ */
6152
+ discover() {
6153
+ const dirs = this.agentId === "claude" ? [this.claudeProjectsDirForCwd()] : this.codexDateDirs();
6154
+ for (const dir of dirs) {
6155
+ let names;
6156
+ try {
6157
+ names = fs.readdirSync(dir);
6158
+ } catch {
6159
+ continue;
6160
+ }
6161
+ for (const name of names) {
6162
+ if (!name.endsWith(".jsonl")) continue;
6163
+ if (this.agentId === "codex" && !name.startsWith("rollout-")) continue;
6164
+ const filePath = path.join(dir, name);
6165
+ if (this.files.has(filePath)) continue;
6166
+ try {
6167
+ const stat = fs.statSync(filePath);
6168
+ if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
6169
+ this.files.set(filePath, { offset: 0, remainder: "", excluded: false });
6170
+ }
6171
+ } catch {
6172
+ }
6173
+ }
6174
+ }
6175
+ }
6176
+ claudeProjectsDirForCwd() {
6177
+ const slug = this.cwd.replace(/[^a-zA-Z0-9]/g, "-");
6178
+ return path.join(this.claudeProjectsDir, slug);
6179
+ }
6180
+ codexDateDirs() {
6181
+ const dirs = /* @__PURE__ */ new Set();
6182
+ for (const ms of [this.sinceMs, Date.now()]) {
6183
+ const d = new Date(ms);
6184
+ const yyyy = String(d.getFullYear());
6185
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
6186
+ const dd = String(d.getDate()).padStart(2, "0");
6187
+ dirs.add(path.join(this.codexSessionsDir, yyyy, mm, dd));
6188
+ }
6189
+ return [...dirs];
6190
+ }
6191
+ // ─── Tailing ────────────────────────────────────
6192
+ drainFile(filePath, tail) {
6193
+ let size;
6194
+ try {
6195
+ size = fs.statSync(filePath).size;
6196
+ } catch {
6197
+ return;
6198
+ }
6199
+ if (size <= tail.offset) return;
6200
+ let fd;
6201
+ try {
6202
+ fd = fs.openSync(filePath, "r");
6203
+ } catch {
6204
+ return;
6205
+ }
6206
+ try {
6207
+ const buf = Buffer.alloc(READ_CHUNK_BYTES);
6208
+ while (tail.offset < size && !tail.excluded) {
6209
+ const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset);
6210
+ if (bytesRead <= 0) break;
6211
+ tail.offset += bytesRead;
6212
+ tail.remainder += buf.toString("utf-8", 0, bytesRead);
6213
+ const lines = tail.remainder.split("\n");
6214
+ tail.remainder = lines.pop() || "";
6215
+ for (const line of lines) {
6216
+ this.handleLine(line, tail);
6217
+ if (tail.excluded) break;
6218
+ }
6219
+ }
6220
+ } catch (err) {
6221
+ console.error(`[transcripts] Read error for ${filePath}:`, err);
6222
+ } finally {
6223
+ try {
6224
+ fs.closeSync(fd);
6225
+ } catch {
6226
+ }
6227
+ }
6228
+ }
6229
+ handleLine(line, tail) {
6230
+ const trimmed = line.trim();
6231
+ if (!trimmed) return;
6232
+ let obj;
6233
+ try {
6234
+ obj = JSON.parse(trimmed);
6235
+ } catch {
6236
+ return;
6237
+ }
6238
+ const entry = this.agentId === "claude" ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail);
6239
+ if (!entry) return;
6240
+ const ms = Date.parse(entry.ts);
6241
+ if (Number.isFinite(ms) && ms < this.sinceMs - DISCOVERY_SLACK_MS) return;
6242
+ this.sink({ ...entry, content: capLength(entry.content) });
6243
+ }
6244
+ // ─── Claude Code format ─────────────────────────
6245
+ parseClaudeLine(obj) {
6246
+ if (!obj || typeof obj !== "object") return null;
6247
+ if (obj.isMeta) return null;
6248
+ if (obj.type !== "user" && obj.type !== "assistant") return null;
6249
+ if (typeof obj.cwd === "string" && stripTrailingSep(obj.cwd) !== this.cwd) return null;
6250
+ if (obj.isSidechain) return null;
6251
+ const message = obj.message;
6252
+ if (!message) return null;
6253
+ const text = extractClaudeText(message.content, obj.type === "user");
6254
+ if (!text) return null;
6255
+ return {
6256
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
6257
+ type: obj.type === "user" ? "user_input" : "terminal_output",
6258
+ content: text
6259
+ };
6260
+ }
6261
+ // ─── Codex CLI format ───────────────────────────
6262
+ parseCodexLine(obj, tail) {
6263
+ if (!obj || typeof obj !== "object") return null;
6264
+ const payload = obj.payload;
6265
+ if (obj.type === "session_meta") {
6266
+ const metaCwd = payload?.cwd;
6267
+ if (typeof metaCwd === "string" && stripTrailingSep(metaCwd) !== this.cwd) {
6268
+ tail.excluded = true;
6269
+ }
6270
+ return null;
6271
+ }
6272
+ if (obj.type !== "event_msg" || !payload || typeof payload !== "object") return null;
6273
+ let type;
6274
+ if (payload.type === "user_message") {
6275
+ type = "user_input";
6276
+ } else if (payload.type === "agent_message") {
6277
+ type = "terminal_output";
6278
+ } else {
6279
+ return null;
6280
+ }
6281
+ const text = typeof payload.message === "string" ? payload.message.trim() : "";
6282
+ if (!text || isCodexNoise(text)) return null;
6283
+ return {
6284
+ ts: typeof obj.timestamp === "string" ? obj.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
6285
+ type,
6286
+ content: text
6287
+ };
6288
+ }
6289
+ };
6290
+ var CLAUDE_NOISE_PREFIXES = [
6291
+ "<local-command-caveat>",
6292
+ "<command-name>",
6293
+ "<command-message>",
6294
+ "<local-command-stdout>",
6295
+ "<bash-input>",
6296
+ "<bash-stdout>",
6297
+ "<bash-stderr>",
6298
+ "<system-reminder>",
6299
+ "<task-notification>",
6300
+ "caveat: the messages below",
6301
+ "[request interrupted"
6302
+ ];
6303
+ var CODEX_NOISE_PREFIXES = [
6304
+ "<environment_context>",
6305
+ "<user_instructions>",
6306
+ "<permissions instructions>",
6307
+ "<turn_aborted>"
6308
+ ];
6309
+ function isClaudeNoise(text) {
6310
+ const lower = text.trimStart().toLowerCase();
6311
+ return CLAUDE_NOISE_PREFIXES.some((p) => lower.startsWith(p));
6312
+ }
6313
+ function isCodexNoise(text) {
6314
+ const lower = text.trimStart().toLowerCase();
6315
+ return CODEX_NOISE_PREFIXES.some((p) => lower.startsWith(p));
6316
+ }
6317
+ function extractClaudeText(content, isUser) {
6318
+ if (typeof content === "string") {
6319
+ const text = content.trim();
6320
+ return text && !isClaudeNoise(text) ? text : "";
6321
+ }
6322
+ if (!Array.isArray(content)) return "";
6323
+ if (isUser && content.some((b) => b?.type === "tool_result")) return "";
6324
+ const parts = [];
6325
+ for (const block of content) {
6326
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
6327
+ const text = block.text.trim();
6328
+ if (text && !isClaudeNoise(text)) parts.push(text);
6329
+ }
6330
+ return parts.join("\n\n");
6331
+ }
6332
+ function stripTrailingSep(p) {
6333
+ return p.length > 1 ? p.replace(/[/\\]+$/, "") : p;
6334
+ }
6335
+ function capLength(str) {
6336
+ if (str.length <= MAX_ENTRY_CHARS) return str;
6337
+ return str.slice(0, MAX_ENTRY_CHARS) + `\u2026 [truncated, ${str.length} total chars]`;
6338
+ }
6339
+
6340
+ // src/main/workerWs.ts
6341
+ import os2 from "os";
6095
6342
  import crypto from "crypto";
6096
6343
  import WsModule from "ws";
6097
6344
  var WS = typeof WebSocket !== "undefined" ? WebSocket : WsModule;
@@ -6131,7 +6378,7 @@ var WorkerWsClient = class {
6131
6378
  // single worker server-side. cwd is included so the same folder maps to the
6132
6379
  // same worker across restarts.
6133
6380
  generateFingerprint(cwd) {
6134
- const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}:${cwd}`;
6381
+ const data = `${os2.hostname()}:${os2.userInfo().username}:${os2.platform()}:${os2.arch()}:${cwd}`;
6135
6382
  return crypto.createHash("sha256").update(data).digest("hex").slice(0, 32);
6136
6383
  }
6137
6384
  setStatus(status) {
@@ -6340,7 +6587,7 @@ var WorkerWsClient = class {
6340
6587
  };
6341
6588
 
6342
6589
  // src/main/timeTracker.ts
6343
- import os2 from "os";
6590
+ import os3 from "os";
6344
6591
  import { randomUUID } from "crypto";
6345
6592
  var DATASTORE_TITLE = "Time Tracking";
6346
6593
  var AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
@@ -6445,7 +6692,7 @@ var TimeTracker = class {
6445
6692
  "Active Time": Math.round(s.activeMs / 6e4),
6446
6693
  "Last Updated": (/* @__PURE__ */ new Date()).toISOString(),
6447
6694
  Agent: s.agentName,
6448
- Worker: os2.hostname(),
6695
+ Worker: os3.hostname(),
6449
6696
  Session: s.sessionUuid,
6450
6697
  Notes: ""
6451
6698
  });
@@ -6884,6 +7131,7 @@ var Orchestrator = class {
6884
7131
  noProjectPollTimer = null;
6885
7132
  noProjectPollCwd = null;
6886
7133
  currentProjectName = null;
7134
+ transcriptTailer = null;
6887
7135
  constructor(settingsDir, events) {
6888
7136
  this.settingsDir = settingsDir;
6889
7137
  this.events = events;
@@ -6935,11 +7183,36 @@ var Orchestrator = class {
6935
7183
  this.saveSettings();
6936
7184
  this.bridge.setLoggingEnabled(enabled);
6937
7185
  if (!enabled) {
6938
- this.bridge.endSession();
7186
+ this.stopChatLogging();
6939
7187
  } else if (this.activeTabId) {
7188
+ const tab = this.tabs.get(this.activeTabId);
7189
+ if (tab) this.startChatLogging(tab.agent, tab.cwd);
7190
+ }
7191
+ }
7192
+ // Agents with native session transcripts (Claude Code, Codex) get exact
7193
+ // chat text tailed from their JSONL logs; everything else falls back to
7194
+ // the terminal screen-scraper bridge.
7195
+ startChatLogging(agent, cwd) {
7196
+ this.stopChatLogging();
7197
+ if (!this.settings.logChat) return;
7198
+ if (supportsTranscriptLogging(agent.id)) {
7199
+ this.transcriptTailer = new TranscriptTailer({
7200
+ agentId: agent.id,
7201
+ cwd,
7202
+ sink: (entry) => this.workerWs.sendChatLog(entry)
7203
+ });
7204
+ this.transcriptTailer.start();
7205
+ } else {
6940
7206
  this.bridge.startSession();
6941
7207
  }
6942
7208
  }
7209
+ stopChatLogging() {
7210
+ if (this.transcriptTailer) {
7211
+ this.transcriptTailer.stop();
7212
+ this.transcriptTailer = null;
7213
+ }
7214
+ this.bridge.endSession();
7215
+ }
6943
7216
  // ─── Settings ───────────────────────────────────
6944
7217
  getActiveProfile() {
6945
7218
  return this.settings.profiles[this.settings.activeProfile] || this.settings.profiles.production || DEFAULT_PROFILES.production;
@@ -6989,13 +7262,13 @@ var Orchestrator = class {
6989
7262
  }
6990
7263
  loadSettings() {
6991
7264
  try {
6992
- fs.mkdirSync(this.settingsDir, { recursive: true });
7265
+ fs2.mkdirSync(this.settingsDir, { recursive: true });
6993
7266
  } catch {
6994
7267
  }
6995
- const settingsPath = path.join(this.settingsDir, "settings.json");
7268
+ const settingsPath = path2.join(this.settingsDir, "settings.json");
6996
7269
  try {
6997
- if (fs.existsSync(settingsPath)) {
6998
- const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
7270
+ if (fs2.existsSync(settingsPath)) {
7271
+ const raw = JSON.parse(fs2.readFileSync(settingsPath, "utf-8"));
6999
7272
  if (!raw.profiles) {
7000
7273
  this.settings = {
7001
7274
  activeProfile: "production",
@@ -7032,10 +7305,10 @@ var Orchestrator = class {
7032
7305
  this.bridge.setLoggingEnabled(!!this.settings.logChat);
7033
7306
  }
7034
7307
  saveSettings() {
7035
- const settingsPath = path.join(this.settingsDir, "settings.json");
7308
+ const settingsPath = path2.join(this.settingsDir, "settings.json");
7036
7309
  try {
7037
- fs.mkdirSync(this.settingsDir, { recursive: true });
7038
- fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
7310
+ fs2.mkdirSync(this.settingsDir, { recursive: true });
7311
+ fs2.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
7039
7312
  } catch (err) {
7040
7313
  log("[settings] Failed to save:", err.message);
7041
7314
  }
@@ -7151,7 +7424,7 @@ var Orchestrator = class {
7151
7424
  this.events.onPtyExit(tabId, exitCode);
7152
7425
  await this.timeTracker.endSession(tabId);
7153
7426
  if (tabId === this.activeTabId) {
7154
- this.bridge.endSession();
7427
+ this.stopChatLogging();
7155
7428
  if (this.currentAgent && isCodingAgent(this.currentAgent)) {
7156
7429
  this.workerWs.disconnect();
7157
7430
  }
@@ -7159,9 +7432,7 @@ var Orchestrator = class {
7159
7432
  const t = this.tabs.get(tabId);
7160
7433
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer);
7161
7434
  });
7162
- if (this.settings.logChat) {
7163
- this.bridge.startSession();
7164
- }
7435
+ this.startChatLogging(agent, cwd);
7165
7436
  const profile = this.getActiveProfile();
7166
7437
  const shouldTrack = opts?.trackTime !== void 0 ? opts.trackTime : profile.trackTime !== false;
7167
7438
  if (shouldTrack) {
@@ -7201,7 +7472,7 @@ var Orchestrator = class {
7201
7472
  tab.ptyManager.kill();
7202
7473
  this.tabs.delete(tabId);
7203
7474
  if (tabId === this.activeTabId) {
7204
- this.bridge.endSession();
7475
+ this.stopChatLogging();
7205
7476
  if (isCodingAgent(tab.agent)) {
7206
7477
  this.workerWs.disconnect();
7207
7478
  }
@@ -7282,7 +7553,7 @@ var Orchestrator = class {
7282
7553
  }
7283
7554
  this.stopNoProjectPolling();
7284
7555
  this.workerWs.connect({
7285
- machine: os3.hostname(),
7556
+ machine: os4.hostname(),
7286
7557
  cwd,
7287
7558
  agent: agent.name
7288
7559
  });
@@ -7352,7 +7623,7 @@ var Orchestrator = class {
7352
7623
  // ─── Shutdown ───────────────────────────────────
7353
7624
  async shutdown() {
7354
7625
  this.stopNoProjectPolling();
7355
- this.bridge.endSession();
7626
+ this.stopChatLogging();
7356
7627
  await this.timeTracker.endAll();
7357
7628
  for (const [, tab] of this.tabs) {
7358
7629
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
@@ -7364,18 +7635,18 @@ var Orchestrator = class {
7364
7635
  };
7365
7636
 
7366
7637
  // src/main/settingsDir.ts
7367
- import path2 from "path";
7368
- import os4 from "os";
7638
+ import path3 from "path";
7639
+ import os5 from "os";
7369
7640
  function getSettingsDir(useElectron) {
7370
7641
  if (useElectron) {
7371
7642
  const { app } = require_electron();
7372
7643
  return app.getPath("userData");
7373
7644
  }
7374
7645
  if (process.platform === "darwin") {
7375
- return path2.join(os4.homedir(), "Library", "Application Support", "ctlsurf-worker");
7646
+ return path3.join(os5.homedir(), "Library", "Application Support", "ctlsurf-worker");
7376
7647
  }
7377
- return path2.join(
7378
- process.env.XDG_CONFIG_HOME || path2.join(os4.homedir(), ".config"),
7648
+ return path3.join(
7649
+ process.env.XDG_CONFIG_HOME || path3.join(os5.homedir(), ".config"),
7379
7650
  "ctlsurf-worker"
7380
7651
  );
7381
7652
  }