@phenx-inc/ctlsurf 0.1.21 → 0.3.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 (35) hide show
  1. package/out/headless/index.mjs +409 -99
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +419 -77
  4. package/out/preload/index.js +12 -8
  5. package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
  8. package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
  10. package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
  11. package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
  12. package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
  14. package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
  16. package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
  17. package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
  18. package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
  20. package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
  21. package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
  22. package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/ctlsurfApi.ts +26 -0
  26. package/src/main/headless.ts +40 -34
  27. package/src/main/index.ts +95 -13
  28. package/src/main/orchestrator.ts +160 -55
  29. package/src/main/timeTracker.ts +223 -0
  30. package/src/main/tui.ts +25 -5
  31. package/src/preload/index.ts +23 -15
  32. package/src/renderer/App.tsx +197 -43
  33. package/src/renderer/components/SettingsDialog.tsx +38 -1
  34. package/src/renderer/components/TerminalPanel.tsx +109 -59
  35. package/src/renderer/styles.css +132 -0
@@ -37,7 +37,7 @@ var require_electron = __commonJS({
37
37
  // src/main/orchestrator.ts
38
38
  import path from "path";
39
39
  import fs from "fs";
40
- import os2 from "os";
40
+ import os3 from "os";
41
41
 
42
42
  // src/main/pty.ts
43
43
  import { createRequire } from "module";
@@ -198,6 +198,25 @@ var CtlsurfApi = class {
198
198
  const folder = await this.request("GET", `/folders/${folderId}`);
199
199
  return folder?.pages || [];
200
200
  }
201
+ async getFolder(folderId) {
202
+ return this.request("GET", `/folders/${folderId}`);
203
+ }
204
+ // ─── Datastore ───────────────────────────────────────
205
+ async getPageBlockSummaries(pageId) {
206
+ return this.request("GET", `/blocks/page/${pageId}/summary`);
207
+ }
208
+ async addRow(blockId, data) {
209
+ return this.request("POST", `/datastore/${blockId}/rows`, { data });
210
+ }
211
+ async updateRow(blockId, rowId, data) {
212
+ return this.request("PUT", `/datastore/${blockId}/rows/${rowId}`, { data });
213
+ }
214
+ async getDatastoreSchema(blockId) {
215
+ return this.request("GET", `/datastore/${blockId}/schema`);
216
+ }
217
+ async updateDatastoreSchema(blockId, columns) {
218
+ return this.request("PUT", `/datastore/${blockId}/schema`, { columns });
219
+ }
201
220
  async findFolderByGitRemote(gitRemote) {
202
221
  const folders = await this.request("GET", "/folders");
203
222
  return folders?.find((f) => f.git_remote === gitRemote || f.root_path === gitRemote) || null;
@@ -567,19 +586,216 @@ var WorkerWsClient = class {
567
586
  }
568
587
  };
569
588
 
570
- // src/main/orchestrator.ts
589
+ // src/main/timeTracker.ts
590
+ import os2 from "os";
591
+ import { randomUUID } from "crypto";
592
+ var DATASTORE_TITLE = "Time Tracking";
593
+ var AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
594
+ var FIRST_CHECKPOINT_DELAY_MS = 30 * 1e3;
595
+ var CHECKPOINT_INTERVAL_MS = 5 * 60 * 1e3;
596
+ var COLUMNS = [
597
+ { name: "Started", type: "date" },
598
+ { name: "Active Time", type: "number" },
599
+ { name: "Agent", type: "text" },
600
+ { name: "Worker", type: "text" },
601
+ { name: "Session", type: "text" },
602
+ { name: "Notes", type: "text" }
603
+ ];
571
604
  function log2(...args) {
605
+ try {
606
+ console.log("[time-tracker]", ...args);
607
+ } catch {
608
+ }
609
+ }
610
+ function findPageByTitle(pages, title) {
611
+ for (const p of pages) {
612
+ if (p?.title === title) return p;
613
+ if (p?.children?.length) {
614
+ const c = findPageByTitle(p.children, title);
615
+ if (c) return c;
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+ var TimeTracker = class {
621
+ api;
622
+ sessions = /* @__PURE__ */ new Map();
623
+ blockCache = /* @__PURE__ */ new Map();
624
+ constructor(api) {
625
+ this.api = api;
626
+ }
627
+ async startSession(tabId, cwd, agentName, idleTimeoutMin) {
628
+ if (this.sessions.has(tabId)) {
629
+ await this.endSession(tabId);
630
+ }
631
+ try {
632
+ const blockId = await this.ensureDatastore(cwd);
633
+ if (!blockId) {
634
+ log2(`No "${AGENT_DATASTORE_PAGE_TITLE}" page found for ${cwd} \u2014 tracking disabled for this session`);
635
+ return;
636
+ }
637
+ const startedAt = Date.now();
638
+ const startedIso = new Date(startedAt).toISOString();
639
+ const sessionUuid = randomUUID();
640
+ const row = await this.api.addRow(blockId, {
641
+ Started: startedIso,
642
+ "Active Time": 0,
643
+ Agent: agentName,
644
+ Worker: os2.hostname(),
645
+ Session: sessionUuid,
646
+ Notes: ""
647
+ });
648
+ const rowId = row?.id;
649
+ if (!rowId) {
650
+ log2("addRow returned no id; aborting tracking", row);
651
+ return;
652
+ }
653
+ const state = {
654
+ blockId,
655
+ rowId,
656
+ cwd,
657
+ startedAt,
658
+ lastActivity: startedAt,
659
+ activeMs: 0,
660
+ idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1e3,
661
+ firstCheckpointTimer: null,
662
+ checkpointTimer: null,
663
+ ended: false
664
+ };
665
+ state.firstCheckpointTimer = setTimeout(() => {
666
+ void this.checkpoint(tabId);
667
+ const live = this.sessions.get(tabId);
668
+ if (live && !live.ended) {
669
+ live.checkpointTimer = setInterval(() => {
670
+ void this.checkpoint(tabId);
671
+ }, CHECKPOINT_INTERVAL_MS);
672
+ }
673
+ }, FIRST_CHECKPOINT_DELAY_MS);
674
+ this.sessions.set(tabId, state);
675
+ log2(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}`);
676
+ } catch (err) {
677
+ log2(`startSession failed: ${err?.message || err}`);
678
+ }
679
+ }
680
+ isTracking(tabId) {
681
+ const s = this.sessions.get(tabId);
682
+ return !!s && !s.ended;
683
+ }
684
+ recordActivity(tabId) {
685
+ const s = this.sessions.get(tabId);
686
+ if (!s || s.ended) return;
687
+ const now = Date.now();
688
+ const delta = now - s.lastActivity;
689
+ if (delta < s.idleTimeoutMs) {
690
+ s.activeMs += delta;
691
+ }
692
+ s.lastActivity = now;
693
+ }
694
+ async endSession(tabId) {
695
+ const s = this.sessions.get(tabId);
696
+ if (!s || s.ended) return;
697
+ s.ended = true;
698
+ if (s.firstCheckpointTimer) clearTimeout(s.firstCheckpointTimer);
699
+ if (s.checkpointTimer) clearInterval(s.checkpointTimer);
700
+ try {
701
+ await this.writeRow(s, Date.now());
702
+ } catch (err) {
703
+ log2(`endSession write failed: ${err?.message || err}`);
704
+ }
705
+ this.sessions.delete(tabId);
706
+ }
707
+ async endAll() {
708
+ const ids = [...this.sessions.keys()];
709
+ await Promise.all(ids.map((id) => this.endSession(id)));
710
+ }
711
+ async checkpoint(tabId) {
712
+ const s = this.sessions.get(tabId);
713
+ if (!s || s.ended) return;
714
+ try {
715
+ await this.writeRow(s, Date.now());
716
+ } catch (err) {
717
+ log2(`checkpoint failed: ${err?.message || err}`);
718
+ }
719
+ }
720
+ async writeRow(s, _endTimeMs) {
721
+ const activeMin = Math.round(s.activeMs / 6e4);
722
+ await this.api.updateRow(s.blockId, s.rowId, {
723
+ "Active Time": activeMin
724
+ });
725
+ }
726
+ async ensureDatastore(cwd) {
727
+ const cached = this.blockCache.get(cwd);
728
+ if (cached) return cached;
729
+ let folder = null;
730
+ try {
731
+ folder = await this.api.findFolderByPath(cwd);
732
+ } catch {
733
+ return null;
734
+ }
735
+ if (!folder?.id) return null;
736
+ const folderDetail = await this.api.getFolder(folder.id);
737
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE);
738
+ if (!agentPage?.id) return null;
739
+ const summaries = await this.api.getPageBlockSummaries(agentPage.id);
740
+ const existing = (summaries || []).find((b) => b?.type === "datastore" && b?.title === DATASTORE_TITLE);
741
+ if (existing?.id) {
742
+ await this.ensureColumns(existing.id);
743
+ this.blockCache.set(cwd, existing.id);
744
+ return existing.id;
745
+ }
746
+ const columns = COLUMNS.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }));
747
+ const created = await this.api.createBlock(agentPage.id, {
748
+ type: "datastore",
749
+ title: DATASTORE_TITLE,
750
+ props: { columns }
751
+ });
752
+ if (created?.id) {
753
+ log2(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`);
754
+ this.blockCache.set(cwd, created.id);
755
+ return created.id;
756
+ }
757
+ return null;
758
+ }
759
+ async ensureColumns(blockId) {
760
+ try {
761
+ const schema = await this.api.getDatastoreSchema(blockId);
762
+ const existingCols = schema.columns || [];
763
+ const existingNames = new Set(existingCols.map((c) => c.name));
764
+ const missing = COLUMNS.filter((c) => !existingNames.has(c.name));
765
+ if (missing.length === 0) return;
766
+ const usedIds = new Set(existingCols.map((c) => c.id));
767
+ let nextIdx = existingCols.length;
768
+ const appended = missing.map((c) => {
769
+ let id = `col_${nextIdx++}`;
770
+ while (usedIds.has(id)) id = `col_${nextIdx++}`;
771
+ usedIds.add(id);
772
+ return { id, name: c.name, type: c.type };
773
+ });
774
+ const merged = [...existingCols, ...appended];
775
+ await this.api.updateDatastoreSchema(blockId, merged);
776
+ log2(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
777
+ } catch (err) {
778
+ log2(`ensureColumns failed: ${err?.message || err}`);
779
+ }
780
+ }
781
+ };
782
+
783
+ // src/main/orchestrator.ts
784
+ function log3(...args) {
572
785
  try {
573
786
  console.log(...args);
574
787
  } catch {
575
788
  }
576
789
  }
790
+ var DEFAULT_IDLE_TIMEOUT_MIN = 15;
577
791
  var DEFAULT_PROFILES = {
578
792
  production: {
579
793
  name: "Production",
580
794
  apiKey: "",
581
795
  baseUrl: "https://app.ctlsurf.com",
582
- dataspacePageId: ""
796
+ dataspacePageId: "",
797
+ trackTime: true,
798
+ idleTimeoutMin: 15
583
799
  }
584
800
  };
585
801
  var TERM_STREAM_INTERVAL_MS = 50;
@@ -590,45 +806,46 @@ var Orchestrator = class {
590
806
  ctlsurfApi = new CtlsurfApi();
591
807
  bridge = new ConversationBridge();
592
808
  workerWs;
809
+ timeTracker = new TimeTracker(this.ctlsurfApi);
593
810
  // State
594
- ptyManager = null;
811
+ tabs = /* @__PURE__ */ new Map();
812
+ activeTabId = null;
595
813
  currentAgent = null;
596
814
  currentCwd = null;
597
815
  settings = {
598
816
  activeProfile: "production",
599
817
  profiles: { ...DEFAULT_PROFILES }
600
818
  };
601
- // Terminal stream batching
602
- termStreamBuffer = "";
603
- termStreamTimer = null;
604
819
  constructor(settingsDir, events) {
605
820
  this.settingsDir = settingsDir;
606
821
  this.events = events;
607
822
  this.workerWs = new WorkerWsClient({
608
823
  onStatusChange: (status) => {
609
- log2(`[worker-ws] Status: ${status}`);
824
+ log3(`[worker-ws] Status: ${status}`);
610
825
  events.onWorkerStatus(status);
611
826
  },
612
827
  onMessage: (message) => {
613
- log2(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
828
+ log3(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
614
829
  events.onWorkerMessage(message);
615
830
  this.workerWs.sendAck(message.id);
616
831
  if (message.type === "prompt" || message.type === "task_dispatch") {
617
- if (this.ptyManager) {
618
- this.ptyManager.write(message.content + "\r");
832
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
833
+ if (activeTab) {
834
+ activeTab.ptyManager.write(message.content + "\r");
619
835
  this.bridge.feedInput(message.content);
620
836
  }
621
837
  }
622
838
  },
623
839
  onRegistered: (data) => {
624
- log2(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
840
+ log3(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
625
841
  events.onWorkerRegistered(data);
626
842
  if (!data.folder_id) {
627
843
  events.onWorkerStatus("no_project");
628
844
  }
629
845
  },
630
846
  onTerminalInput: (data) => {
631
- this.ptyManager?.write(data);
847
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
848
+ activeTab?.ptyManager.write(data);
632
849
  }
633
850
  });
634
851
  this.bridge.setWsClient(this.workerWs);
@@ -658,7 +875,7 @@ var Orchestrator = class {
658
875
  const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
659
876
  this.ctlsurfApi.setBaseUrl(baseUrl);
660
877
  this.workerWs.setBaseUrl(baseUrl);
661
- log2(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
878
+ log3(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
662
879
  }
663
880
  loadSettings() {
664
881
  try {
@@ -682,7 +899,7 @@ var Orchestrator = class {
682
899
  }
683
900
  };
684
901
  this.saveSettings();
685
- log2("[settings] Migrated legacy settings to profiles");
902
+ log3("[settings] Migrated legacy settings to profiles");
686
903
  } else {
687
904
  this.settings = raw;
688
905
  if (!this.settings.profiles.production) {
@@ -704,7 +921,7 @@ var Orchestrator = class {
704
921
  fs.mkdirSync(this.settingsDir, { recursive: true });
705
922
  fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
706
923
  } catch (err) {
707
- log2("[settings] Failed to save:", err.message);
924
+ log3("[settings] Failed to save:", err.message);
708
925
  }
709
926
  }
710
927
  overrideApiKey(key) {
@@ -724,7 +941,9 @@ var Orchestrator = class {
724
941
  name: p.name,
725
942
  baseUrl: p.baseUrl,
726
943
  hasApiKey: !!p.apiKey,
727
- dataspacePageId: p.dataspacePageId || null
944
+ dataspacePageId: p.dataspacePageId || null,
945
+ trackTime: p.trackTime !== false,
946
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
728
947
  }))
729
948
  };
730
949
  }
@@ -736,7 +955,9 @@ var Orchestrator = class {
736
955
  name: p.name,
737
956
  baseUrl: p.baseUrl,
738
957
  hasApiKey: !!p.apiKey,
739
- dataspacePageId: p.dataspacePageId || ""
958
+ dataspacePageId: p.dataspacePageId || "",
959
+ trackTime: p.trackTime !== false,
960
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
740
961
  };
741
962
  }
742
963
  saveProfile(profileId, data) {
@@ -745,7 +966,9 @@ var Orchestrator = class {
745
966
  name: data.name,
746
967
  apiKey: data.apiKey !== void 0 ? data.apiKey : existing?.apiKey || "",
747
968
  baseUrl: data.baseUrl || "https://app.ctlsurf.com",
748
- dataspacePageId: data.dataspacePageId || ""
969
+ dataspacePageId: data.dataspacePageId || "",
970
+ trackTime: data.trackTime !== void 0 ? data.trackTime : existing?.trackTime !== false,
971
+ idleTimeoutMin: data.idleTimeoutMin !== void 0 ? data.idleTimeoutMin : existing?.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
749
972
  };
750
973
  this.saveSettings();
751
974
  if (profileId === this.settings.activeProfile) {
@@ -782,33 +1005,55 @@ var Orchestrator = class {
782
1005
  this.saveSettings();
783
1006
  return { ok: true };
784
1007
  }
785
- // ─── PTY & Agent ────────────────────────────────
786
- async spawnAgent(agent, cwd) {
787
- if (this.ptyManager) {
788
- this.bridge.endSession();
789
- this.ptyManager.kill();
1008
+ // ─── PTY & Agent (multi-tab) ─────────────────────
1009
+ async spawnAgent(tabId, agent, cwd, opts) {
1010
+ const existing = this.tabs.get(tabId);
1011
+ if (existing) {
1012
+ if (existing.termStreamTimer) clearTimeout(existing.termStreamTimer);
1013
+ existing.ptyManager.kill();
1014
+ this.tabs.delete(tabId);
790
1015
  }
791
1016
  this.currentAgent = agent;
792
1017
  const prevCwd = this.currentCwd;
793
1018
  this.currentCwd = cwd;
1019
+ this.activeTabId = tabId;
794
1020
  if (prevCwd !== cwd) {
795
1021
  this.events.onCwdChanged();
796
1022
  }
797
- this.ptyManager = new PtyManager(agent, cwd);
798
- this.ptyManager.onData((data) => {
799
- this.events.onPtyData(data);
800
- this.bridge.feedOutput(data);
801
- this.streamTerminalData(data);
1023
+ const ptyManager = new PtyManager(agent, cwd);
1024
+ const tab = { ptyManager, agent, cwd, termStreamBuffer: "", termStreamTimer: null };
1025
+ this.tabs.set(tabId, tab);
1026
+ ptyManager.onData((data) => {
1027
+ this.events.onPtyData(tabId, data);
1028
+ this.timeTracker.recordActivity(tabId);
1029
+ if (tabId === this.activeTabId) {
1030
+ this.bridge.feedOutput(data);
1031
+ this.streamTerminalData(tabId, data);
1032
+ }
802
1033
  });
803
- const thisPtyManager = this.ptyManager;
804
- this.ptyManager.onExit(async (exitCode) => {
805
- this.events.onPtyExit(exitCode);
806
- this.bridge.endSession();
807
- if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
808
- this.workerWs.disconnect();
1034
+ ptyManager.onExit(async (exitCode) => {
1035
+ this.events.onPtyExit(tabId, exitCode);
1036
+ await this.timeTracker.endSession(tabId);
1037
+ if (tabId === this.activeTabId) {
1038
+ this.bridge.endSession();
1039
+ if (this.currentAgent && isCodingAgent(this.currentAgent)) {
1040
+ this.workerWs.disconnect();
1041
+ }
809
1042
  }
1043
+ const t = this.tabs.get(tabId);
1044
+ if (t?.termStreamTimer) clearTimeout(t.termStreamTimer);
810
1045
  });
811
1046
  this.bridge.startSession();
1047
+ const profile = this.getActiveProfile();
1048
+ const shouldTrack = opts?.trackTime !== void 0 ? opts.trackTime : profile.trackTime !== false;
1049
+ if (shouldTrack) {
1050
+ void this.timeTracker.startSession(
1051
+ tabId,
1052
+ cwd,
1053
+ agent.name,
1054
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
1055
+ );
1056
+ }
812
1057
  if (isCodingAgent(agent)) {
813
1058
  this.connectWorkerWs(agent, cwd);
814
1059
  } else {
@@ -816,21 +1061,66 @@ var Orchestrator = class {
816
1061
  this.checkProjectStatus(cwd);
817
1062
  }
818
1063
  }
819
- writePty(data) {
820
- this.ptyManager?.write(data);
821
- this.bridge.feedInput(data);
1064
+ writePty(tabId, data) {
1065
+ this.tabs.get(tabId)?.ptyManager.write(data);
1066
+ if (tabId === this.activeTabId) {
1067
+ this.bridge.feedInput(data);
1068
+ }
822
1069
  }
823
- resizePty(cols, rows) {
824
- this.ptyManager?.resize(cols, rows);
825
- this.bridge.resize(cols, rows);
826
- this.workerWs.sendTerminalResize(cols, rows);
1070
+ resizePty(tabId, cols, rows) {
1071
+ this.tabs.get(tabId)?.ptyManager.resize(cols, rows);
1072
+ if (tabId === this.activeTabId) {
1073
+ this.bridge.resize(cols, rows);
1074
+ this.workerWs.sendTerminalResize(cols, rows);
1075
+ }
827
1076
  }
828
- async killAgent() {
829
- this.bridge.endSession();
830
- this.ptyManager?.kill();
831
- this.ptyManager = null;
832
- if (this.currentAgent && isCodingAgent(this.currentAgent)) {
833
- this.workerWs.disconnect();
1077
+ async killTab(tabId) {
1078
+ const tab = this.tabs.get(tabId);
1079
+ if (!tab) return;
1080
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
1081
+ await this.timeTracker.endSession(tabId);
1082
+ tab.ptyManager.kill();
1083
+ this.tabs.delete(tabId);
1084
+ if (tabId === this.activeTabId) {
1085
+ this.bridge.endSession();
1086
+ if (isCodingAgent(tab.agent)) {
1087
+ this.workerWs.disconnect();
1088
+ }
1089
+ const remaining = [...this.tabs.keys()];
1090
+ this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
1091
+ }
1092
+ }
1093
+ setActiveTab(tabId) {
1094
+ this.activeTabId = tabId;
1095
+ const tab = this.tabs.get(tabId);
1096
+ if (tab) {
1097
+ this.currentAgent = tab.agent;
1098
+ this.currentCwd = tab.cwd;
1099
+ }
1100
+ }
1101
+ getTabIds() {
1102
+ return [...this.tabs.keys()];
1103
+ }
1104
+ // ─── Tracking control (active tab) ──────────────
1105
+ isActiveTabTracking() {
1106
+ if (!this.activeTabId) return false;
1107
+ return this.timeTracker.isTracking(this.activeTabId);
1108
+ }
1109
+ async setActiveTabTracking(enabled) {
1110
+ if (!this.activeTabId) return;
1111
+ const tab = this.tabs.get(this.activeTabId);
1112
+ if (!tab) return;
1113
+ if (enabled) {
1114
+ if (this.timeTracker.isTracking(this.activeTabId)) return;
1115
+ const profile = this.getActiveProfile();
1116
+ await this.timeTracker.startSession(
1117
+ this.activeTabId,
1118
+ tab.cwd,
1119
+ tab.agent.name,
1120
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
1121
+ );
1122
+ } else {
1123
+ await this.timeTracker.endSession(this.activeTabId);
834
1124
  }
835
1125
  }
836
1126
  // ─── Worker WebSocket ───────────────────────────
@@ -838,11 +1128,11 @@ var Orchestrator = class {
838
1128
  const profile = this.getActiveProfile();
839
1129
  const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
840
1130
  if (!apiKey) {
841
- log2("[worker-ws] No API key, skipping WS connect");
1131
+ log3("[worker-ws] No API key, skipping WS connect");
842
1132
  return;
843
1133
  }
844
1134
  this.workerWs.connect({
845
- machine: os2.hostname(),
1135
+ machine: os3.hostname(),
846
1136
  cwd,
847
1137
  agent: agent.name
848
1138
  });
@@ -861,44 +1151,46 @@ var Orchestrator = class {
861
1151
  this.events.onWorkerStatus("no_project");
862
1152
  }
863
1153
  }
864
- streamTerminalData(data) {
865
- this.termStreamBuffer += data;
866
- if (!this.termStreamTimer) {
867
- this.termStreamTimer = setTimeout(() => {
868
- if (this.termStreamBuffer) {
869
- this.workerWs.sendTerminalData(this.termStreamBuffer);
870
- this.termStreamBuffer = "";
1154
+ streamTerminalData(tabId, data) {
1155
+ const tab = this.tabs.get(tabId);
1156
+ if (!tab) return;
1157
+ tab.termStreamBuffer += data;
1158
+ if (!tab.termStreamTimer) {
1159
+ tab.termStreamTimer = setTimeout(() => {
1160
+ if (tab.termStreamBuffer) {
1161
+ this.workerWs.sendTerminalData(tab.termStreamBuffer);
1162
+ tab.termStreamBuffer = "";
871
1163
  }
872
- this.termStreamTimer = null;
1164
+ tab.termStreamTimer = null;
873
1165
  }, TERM_STREAM_INTERVAL_MS);
874
1166
  }
875
1167
  }
876
1168
  // ─── Shutdown ───────────────────────────────────
877
1169
  async shutdown() {
878
1170
  this.bridge.endSession();
879
- this.ptyManager?.kill();
880
- this.ptyManager = null;
881
- this.workerWs.disconnect();
882
- if (this.termStreamTimer) {
883
- clearTimeout(this.termStreamTimer);
884
- this.termStreamTimer = null;
1171
+ await this.timeTracker.endAll();
1172
+ for (const [, tab] of this.tabs) {
1173
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
1174
+ tab.ptyManager.kill();
885
1175
  }
1176
+ this.tabs.clear();
1177
+ this.workerWs.disconnect();
886
1178
  }
887
1179
  };
888
1180
 
889
1181
  // src/main/settingsDir.ts
890
1182
  import path2 from "path";
891
- import os3 from "os";
1183
+ import os4 from "os";
892
1184
  function getSettingsDir(useElectron) {
893
1185
  if (useElectron) {
894
1186
  const { app } = require_electron();
895
1187
  return app.getPath("userData");
896
1188
  }
897
1189
  if (process.platform === "darwin") {
898
- return path2.join(os3.homedir(), "Library", "Application Support", "ctlsurf-worker");
1190
+ return path2.join(os4.homedir(), "Library", "Application Support", "ctlsurf-worker");
899
1191
  }
900
1192
  return path2.join(
901
- process.env.XDG_CONFIG_HOME || path2.join(os3.homedir(), ".config"),
1193
+ process.env.XDG_CONFIG_HOME || path2.join(os4.homedir(), ".config"),
902
1194
  "ctlsurf-worker"
903
1195
  );
904
1196
  }
@@ -1005,11 +1297,12 @@ var Tui = class {
1005
1297
  * Show an interactive agent picker modal.
1006
1298
  * Uses alternate screen just for the picker, then exits back to normal.
1007
1299
  */
1008
- showAgentPicker(agents) {
1300
+ showAgentPicker(agents, options) {
1009
1301
  return new Promise((resolve) => {
1010
1302
  let selected = 0;
1303
+ let trackTime = options.initialTrackTime;
1011
1304
  const modalWidth = 44;
1012
- const modalHeight = agents.length + 4;
1305
+ const modalHeight = agents.length + 4 + 2;
1013
1306
  const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2));
1014
1307
  const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2));
1015
1308
  this.write(`${CSI}?1049h`);
@@ -1043,9 +1336,19 @@ var Tui = class {
1043
1336
  const pad = " ".repeat(Math.max(0, modalWidth - 2 - contentLen));
1044
1337
  this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`);
1045
1338
  }
1046
- const botRow = startRow + 3 + agents.length;
1339
+ const innerSep = "\u251C" + "\u2500".repeat(modalWidth - 2) + "\u2524";
1340
+ const sepRow = startRow + 3 + agents.length;
1341
+ this.write(`${CSI}${sepRow};${startCol}H${BG_MODAL}${FG_DIM}${innerSep}${RESET}`);
1342
+ const trackRow = sepRow + 1;
1343
+ const checkbox = trackTime ? `${FG_GREEN}[\u2713]${RESET}${BG_MODAL}` : `${FG_DIM}[ ]${RESET}${BG_MODAL}`;
1344
+ const trackLabelFg = trackTime ? FG_WHITE : FG_DIM;
1345
+ const trackContent = ` ${checkbox} ${trackLabelFg}Track time${RESET}${BG_MODAL}`;
1346
+ const trackContentLen = 2 + 3 + 1 + "Track time".length;
1347
+ const trackPad = " ".repeat(Math.max(0, modalWidth - 2 - trackContentLen));
1348
+ this.write(`${CSI}${trackRow};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${trackContent}${trackPad}${FG_DIM}\u2502${RESET}`);
1349
+ const botRow = trackRow + 1;
1047
1350
  this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`);
1048
- const hint = "\u2191\u2193 navigate \xB7 Enter select \xB7 q quit";
1351
+ const hint = "\u2191\u2193 navigate \xB7 Enter select \xB7 t track \xB7 q quit";
1049
1352
  const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2));
1050
1353
  this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`);
1051
1354
  };
@@ -1062,9 +1365,12 @@ var Tui = class {
1062
1365
  } else if (key === "\x1B[B" || key === "j") {
1063
1366
  selected = (selected + 1) % agents.length;
1064
1367
  drawModal();
1368
+ } else if (key === "t" || key === "T" || key === " ") {
1369
+ trackTime = !trackTime;
1370
+ drawModal();
1065
1371
  } else if (key === "\r" || key === "\n") {
1066
1372
  cleanup();
1067
- resolve(selected);
1373
+ resolve({ agentIdx: selected, trackTime });
1068
1374
  } else if (key === "q" || key === "\x1B" || key === "") {
1069
1375
  cleanup();
1070
1376
  this.write(`${CSI}?25h`);
@@ -1188,31 +1494,11 @@ async function main() {
1188
1494
  const settingsDir = getSettingsDir(false);
1189
1495
  const tui = new Tui();
1190
1496
  const agents = getBuiltinAgents();
1191
- let agent;
1192
- if (args.agent) {
1193
- const found = agents.find((a) => a.id === args.agent);
1194
- agent = found || {
1195
- id: args.agent,
1196
- name: args.agent,
1197
- command: args.agent,
1198
- args: [],
1199
- description: `Custom agent: ${args.agent}`
1200
- };
1201
- } else {
1202
- const selectedIdx = await tui.showAgentPicker(agents);
1203
- agent = agents[selectedIdx];
1204
- }
1205
- tui.update({
1206
- agentName: agent.name,
1207
- cwd: args.cwd,
1208
- mode: "terminal"
1209
- });
1210
- tui.init();
1211
1497
  const orchestrator = new Orchestrator(settingsDir, {
1212
- onPtyData: (data) => {
1498
+ onPtyData: (_tabId, data) => {
1213
1499
  tui.writePtyData(data);
1214
1500
  },
1215
- onPtyExit: (code) => {
1501
+ onPtyExit: (_tabId, code) => {
1216
1502
  tui.destroy();
1217
1503
  console.log(`Agent exited with code ${code}`);
1218
1504
  orchestrator.shutdown().then(() => process.exit(code));
@@ -1233,12 +1519,36 @@ async function main() {
1233
1519
  if (args.profile) orchestrator.switchProfile(args.profile);
1234
1520
  if (args.apiKey) orchestrator.overrideApiKey(args.apiKey);
1235
1521
  if (args.baseUrl) orchestrator.overrideBaseUrl(args.baseUrl);
1522
+ let agent;
1523
+ let trackTimeOverride;
1524
+ if (args.agent) {
1525
+ const found = agents.find((a) => a.id === args.agent);
1526
+ agent = found || {
1527
+ id: args.agent,
1528
+ name: args.agent,
1529
+ command: args.agent,
1530
+ args: [],
1531
+ description: `Custom agent: ${args.agent}`
1532
+ };
1533
+ } else {
1534
+ const initialTrackTime = orchestrator.getActiveProfile().trackTime !== false;
1535
+ const picked = await tui.showAgentPicker(agents, { initialTrackTime });
1536
+ agent = agents[picked.agentIdx];
1537
+ trackTimeOverride = picked.trackTime;
1538
+ }
1539
+ tui.update({
1540
+ agentName: agent.name,
1541
+ cwd: args.cwd,
1542
+ mode: "terminal"
1543
+ });
1544
+ tui.init();
1545
+ const HEADLESS_TAB = "headless";
1236
1546
  const ptySize = tui.getPtySize();
1237
- await orchestrator.spawnAgent(agent, args.cwd);
1238
- orchestrator.resizePty(ptySize.cols, ptySize.rows);
1547
+ await orchestrator.spawnAgent(HEADLESS_TAB, agent, args.cwd, { trackTime: trackTimeOverride });
1548
+ orchestrator.resizePty(HEADLESS_TAB, ptySize.cols, ptySize.rows);
1239
1549
  if (isCodingAgent(agent)) {
1240
1550
  setTimeout(() => {
1241
- orchestrator.writePty("hello\r");
1551
+ orchestrator.writePty(HEADLESS_TAB, "hello\r");
1242
1552
  }, 1e3);
1243
1553
  }
1244
1554
  const SCROLL_UP_RE = /\x1b\[<64;\d+;\d+M/;
@@ -1255,7 +1565,7 @@ async function main() {
1255
1565
  if (SCROLL_UP_RE.test(str) || SCROLL_DOWN_RE.test(str)) {
1256
1566
  return;
1257
1567
  }
1258
- orchestrator.writePty(str);
1568
+ orchestrator.writePty(HEADLESS_TAB, str);
1259
1569
  });
1260
1570
  }
1261
1571
  process.stdout.on("resize", () => {
@@ -1263,7 +1573,7 @@ async function main() {
1263
1573
  const rows = process.stdout.rows || 24;
1264
1574
  tui.resize(cols, rows);
1265
1575
  const size = tui.getPtySize();
1266
- orchestrator.resizePty(size.cols, size.rows);
1576
+ orchestrator.resizePty(HEADLESS_TAB, size.cols, size.rows);
1267
1577
  });
1268
1578
  const shutdown = async () => {
1269
1579
  if (process.stdin.isTTY) {