@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
package/out/main/index.js CHANGED
@@ -176,6 +176,25 @@ class CtlsurfApi {
176
176
  const folder = await this.request("GET", `/folders/${folderId}`);
177
177
  return folder?.pages || [];
178
178
  }
179
+ async getFolder(folderId) {
180
+ return this.request("GET", `/folders/${folderId}`);
181
+ }
182
+ // ─── Datastore ───────────────────────────────────────
183
+ async getPageBlockSummaries(pageId) {
184
+ return this.request("GET", `/blocks/page/${pageId}/summary`);
185
+ }
186
+ async addRow(blockId, data) {
187
+ return this.request("POST", `/datastore/${blockId}/rows`, { data });
188
+ }
189
+ async updateRow(blockId, rowId, data) {
190
+ return this.request("PUT", `/datastore/${blockId}/rows/${rowId}`, { data });
191
+ }
192
+ async getDatastoreSchema(blockId) {
193
+ return this.request("GET", `/datastore/${blockId}/schema`);
194
+ }
195
+ async updateDatastoreSchema(blockId, columns) {
196
+ return this.request("PUT", `/datastore/${blockId}/schema`, { columns });
197
+ }
179
198
  async findFolderByGitRemote(gitRemote) {
180
199
  const folders = await this.request("GET", "/folders");
181
200
  return folders?.find((f) => f.git_remote === gitRemote || f.root_path === gitRemote) || null;
@@ -4185,7 +4204,7 @@ function requireWebsocketServer() {
4185
4204
  }
4186
4205
  requireWebsocketServer();
4187
4206
  const WS = typeof WebSocket !== "undefined" ? WebSocket : WebSocket$1;
4188
- function log$2(...args) {
4207
+ function log$3(...args) {
4189
4208
  try {
4190
4209
  console.log(...args);
4191
4210
  } catch {
@@ -4281,7 +4300,7 @@ class WorkerWsClient {
4281
4300
  }
4282
4301
  doConnect() {
4283
4302
  if (!this.apiKey || !this.registration) {
4284
- log$2("[worker-ws] No API key or registration, skipping connect");
4303
+ log$3("[worker-ws] No API key or registration, skipping connect");
4285
4304
  return;
4286
4305
  }
4287
4306
  this.clearTimers();
@@ -4304,22 +4323,22 @@ class WorkerWsClient {
4304
4323
  doConnectNow() {
4305
4324
  if (!this.apiKey || !this.registration) return;
4306
4325
  if (!this.shouldReconnect) {
4307
- log$2("[worker-ws] shouldReconnect is false, aborting connect");
4326
+ log$3("[worker-ws] shouldReconnect is false, aborting connect");
4308
4327
  return;
4309
4328
  }
4310
4329
  this.setStatus("connecting");
4311
4330
  const wsBase = this.baseUrl.replace(/^http/, "ws");
4312
4331
  const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`;
4313
- log$2(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
4332
+ log$3(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
4314
4333
  try {
4315
4334
  this.ws = new WS(url);
4316
4335
  } catch (err) {
4317
- log$2("[worker-ws] Failed to create WebSocket:", err);
4336
+ log$3("[worker-ws] Failed to create WebSocket:", err);
4318
4337
  this.scheduleReconnect();
4319
4338
  return;
4320
4339
  }
4321
4340
  this.ws.onopen = () => {
4322
- log$2("[worker-ws] Connected, sending register");
4341
+ log$3("[worker-ws] Connected, sending register");
4323
4342
  this.reconnectDelay = RECONNECT_DELAY_MS;
4324
4343
  this.send({
4325
4344
  type: "register",
@@ -4332,11 +4351,11 @@ class WorkerWsClient {
4332
4351
  const data = JSON.parse(String(event.data));
4333
4352
  this.handleMessage(data);
4334
4353
  } catch (err) {
4335
- log$2("[worker-ws] Failed to parse message:", err);
4354
+ log$3("[worker-ws] Failed to parse message:", err);
4336
4355
  }
4337
4356
  };
4338
4357
  this.ws.onclose = (event) => {
4339
- log$2(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
4358
+ log$3(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
4340
4359
  this.ws = null;
4341
4360
  this.clearHeartbeat();
4342
4361
  this.setStatus("disconnected");
@@ -4345,7 +4364,7 @@ class WorkerWsClient {
4345
4364
  }
4346
4365
  };
4347
4366
  this.ws.onerror = () => {
4348
- log$2("[worker-ws] WebSocket error");
4367
+ log$3("[worker-ws] WebSocket error");
4349
4368
  };
4350
4369
  }
4351
4370
  handleMessage(data) {
@@ -4373,7 +4392,7 @@ class WorkerWsClient {
4373
4392
  break;
4374
4393
  }
4375
4394
  case "approved": {
4376
- log$2("[worker-ws] Worker approved!");
4395
+ log$3("[worker-ws] Worker approved!");
4377
4396
  this.setStatus("connected");
4378
4397
  break;
4379
4398
  }
@@ -4431,18 +4450,211 @@ class WorkerWsClient {
4431
4450
  }
4432
4451
  }
4433
4452
  }
4453
+ const DATASTORE_TITLE = "Time Tracking";
4454
+ const AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
4455
+ const FIRST_CHECKPOINT_DELAY_MS = 30 * 1e3;
4456
+ const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1e3;
4457
+ const COLUMNS = [
4458
+ { name: "Started", type: "date" },
4459
+ { name: "Active Time", type: "number" },
4460
+ { name: "Agent", type: "text" },
4461
+ { name: "Worker", type: "text" },
4462
+ { name: "Session", type: "text" },
4463
+ { name: "Notes", type: "text" }
4464
+ ];
4465
+ function log$2(...args) {
4466
+ try {
4467
+ console.log("[time-tracker]", ...args);
4468
+ } catch {
4469
+ }
4470
+ }
4471
+ function findPageByTitle(pages, title) {
4472
+ for (const p of pages) {
4473
+ if (p?.title === title) return p;
4474
+ if (p?.children?.length) {
4475
+ const c = findPageByTitle(p.children, title);
4476
+ if (c) return c;
4477
+ }
4478
+ }
4479
+ return null;
4480
+ }
4481
+ class TimeTracker {
4482
+ api;
4483
+ sessions = /* @__PURE__ */ new Map();
4484
+ blockCache = /* @__PURE__ */ new Map();
4485
+ constructor(api) {
4486
+ this.api = api;
4487
+ }
4488
+ async startSession(tabId, cwd, agentName, idleTimeoutMin) {
4489
+ if (this.sessions.has(tabId)) {
4490
+ await this.endSession(tabId);
4491
+ }
4492
+ try {
4493
+ const blockId = await this.ensureDatastore(cwd);
4494
+ if (!blockId) {
4495
+ log$2(`No "${AGENT_DATASTORE_PAGE_TITLE}" page found for ${cwd} — tracking disabled for this session`);
4496
+ return;
4497
+ }
4498
+ const startedAt = Date.now();
4499
+ const startedIso = new Date(startedAt).toISOString();
4500
+ const sessionUuid = require$$1.randomUUID();
4501
+ const row = await this.api.addRow(blockId, {
4502
+ Started: startedIso,
4503
+ "Active Time": 0,
4504
+ Agent: agentName,
4505
+ Worker: os.hostname(),
4506
+ Session: sessionUuid,
4507
+ Notes: ""
4508
+ });
4509
+ const rowId = row?.id;
4510
+ if (!rowId) {
4511
+ log$2("addRow returned no id; aborting tracking", row);
4512
+ return;
4513
+ }
4514
+ const state = {
4515
+ blockId,
4516
+ rowId,
4517
+ cwd,
4518
+ startedAt,
4519
+ lastActivity: startedAt,
4520
+ activeMs: 0,
4521
+ idleTimeoutMs: Math.max(1, idleTimeoutMin) * 60 * 1e3,
4522
+ firstCheckpointTimer: null,
4523
+ checkpointTimer: null,
4524
+ ended: false
4525
+ };
4526
+ state.firstCheckpointTimer = setTimeout(() => {
4527
+ void this.checkpoint(tabId);
4528
+ const live = this.sessions.get(tabId);
4529
+ if (live && !live.ended) {
4530
+ live.checkpointTimer = setInterval(() => {
4531
+ void this.checkpoint(tabId);
4532
+ }, CHECKPOINT_INTERVAL_MS);
4533
+ }
4534
+ }, FIRST_CHECKPOINT_DELAY_MS);
4535
+ this.sessions.set(tabId, state);
4536
+ log$2(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}`);
4537
+ } catch (err) {
4538
+ log$2(`startSession failed: ${err?.message || err}`);
4539
+ }
4540
+ }
4541
+ isTracking(tabId) {
4542
+ const s = this.sessions.get(tabId);
4543
+ return !!s && !s.ended;
4544
+ }
4545
+ recordActivity(tabId) {
4546
+ const s = this.sessions.get(tabId);
4547
+ if (!s || s.ended) return;
4548
+ const now = Date.now();
4549
+ const delta = now - s.lastActivity;
4550
+ if (delta < s.idleTimeoutMs) {
4551
+ s.activeMs += delta;
4552
+ }
4553
+ s.lastActivity = now;
4554
+ }
4555
+ async endSession(tabId) {
4556
+ const s = this.sessions.get(tabId);
4557
+ if (!s || s.ended) return;
4558
+ s.ended = true;
4559
+ if (s.firstCheckpointTimer) clearTimeout(s.firstCheckpointTimer);
4560
+ if (s.checkpointTimer) clearInterval(s.checkpointTimer);
4561
+ try {
4562
+ await this.writeRow(s, Date.now());
4563
+ } catch (err) {
4564
+ log$2(`endSession write failed: ${err?.message || err}`);
4565
+ }
4566
+ this.sessions.delete(tabId);
4567
+ }
4568
+ async endAll() {
4569
+ const ids = [...this.sessions.keys()];
4570
+ await Promise.all(ids.map((id) => this.endSession(id)));
4571
+ }
4572
+ async checkpoint(tabId) {
4573
+ const s = this.sessions.get(tabId);
4574
+ if (!s || s.ended) return;
4575
+ try {
4576
+ await this.writeRow(s, Date.now());
4577
+ } catch (err) {
4578
+ log$2(`checkpoint failed: ${err?.message || err}`);
4579
+ }
4580
+ }
4581
+ async writeRow(s, _endTimeMs) {
4582
+ const activeMin = Math.round(s.activeMs / 6e4);
4583
+ await this.api.updateRow(s.blockId, s.rowId, {
4584
+ "Active Time": activeMin
4585
+ });
4586
+ }
4587
+ async ensureDatastore(cwd) {
4588
+ const cached = this.blockCache.get(cwd);
4589
+ if (cached) return cached;
4590
+ let folder = null;
4591
+ try {
4592
+ folder = await this.api.findFolderByPath(cwd);
4593
+ } catch {
4594
+ return null;
4595
+ }
4596
+ if (!folder?.id) return null;
4597
+ const folderDetail = await this.api.getFolder(folder.id);
4598
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE);
4599
+ if (!agentPage?.id) return null;
4600
+ const summaries = await this.api.getPageBlockSummaries(agentPage.id);
4601
+ const existing = (summaries || []).find((b) => b?.type === "datastore" && b?.title === DATASTORE_TITLE);
4602
+ if (existing?.id) {
4603
+ await this.ensureColumns(existing.id);
4604
+ this.blockCache.set(cwd, existing.id);
4605
+ return existing.id;
4606
+ }
4607
+ const columns = COLUMNS.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }));
4608
+ const created = await this.api.createBlock(agentPage.id, {
4609
+ type: "datastore",
4610
+ title: DATASTORE_TITLE,
4611
+ props: { columns }
4612
+ });
4613
+ if (created?.id) {
4614
+ log$2(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`);
4615
+ this.blockCache.set(cwd, created.id);
4616
+ return created.id;
4617
+ }
4618
+ return null;
4619
+ }
4620
+ async ensureColumns(blockId) {
4621
+ try {
4622
+ const schema = await this.api.getDatastoreSchema(blockId);
4623
+ const existingCols = schema.columns || [];
4624
+ const existingNames = new Set(existingCols.map((c) => c.name));
4625
+ const missing = COLUMNS.filter((c) => !existingNames.has(c.name));
4626
+ if (missing.length === 0) return;
4627
+ const usedIds = new Set(existingCols.map((c) => c.id));
4628
+ let nextIdx = existingCols.length;
4629
+ const appended = missing.map((c) => {
4630
+ let id = `col_${nextIdx++}`;
4631
+ while (usedIds.has(id)) id = `col_${nextIdx++}`;
4632
+ usedIds.add(id);
4633
+ return { id, name: c.name, type: c.type };
4634
+ });
4635
+ const merged = [...existingCols, ...appended];
4636
+ await this.api.updateDatastoreSchema(blockId, merged);
4637
+ log$2(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
4638
+ } catch (err) {
4639
+ log$2(`ensureColumns failed: ${err?.message || err}`);
4640
+ }
4641
+ }
4642
+ }
4434
4643
  function log$1(...args) {
4435
4644
  try {
4436
4645
  console.log(...args);
4437
4646
  } catch {
4438
4647
  }
4439
4648
  }
4649
+ const DEFAULT_IDLE_TIMEOUT_MIN = 15;
4440
4650
  const DEFAULT_PROFILES = {
4441
4651
  production: {
4442
4652
  name: "Production",
4443
4653
  apiKey: "",
4444
4654
  baseUrl: "https://app.ctlsurf.com",
4445
- dataspacePageId: ""
4655
+ dataspacePageId: "",
4656
+ trackTime: true,
4657
+ idleTimeoutMin: 15
4446
4658
  }
4447
4659
  };
4448
4660
  const TERM_STREAM_INTERVAL_MS = 50;
@@ -4453,17 +4665,16 @@ class Orchestrator {
4453
4665
  ctlsurfApi = new CtlsurfApi();
4454
4666
  bridge = new ConversationBridge();
4455
4667
  workerWs;
4668
+ timeTracker = new TimeTracker(this.ctlsurfApi);
4456
4669
  // State
4457
- ptyManager = null;
4670
+ tabs = /* @__PURE__ */ new Map();
4671
+ activeTabId = null;
4458
4672
  currentAgent = null;
4459
4673
  currentCwd = null;
4460
4674
  settings = {
4461
4675
  activeProfile: "production",
4462
4676
  profiles: { ...DEFAULT_PROFILES }
4463
4677
  };
4464
- // Terminal stream batching
4465
- termStreamBuffer = "";
4466
- termStreamTimer = null;
4467
4678
  constructor(settingsDir, events) {
4468
4679
  this.settingsDir = settingsDir;
4469
4680
  this.events = events;
@@ -4477,8 +4688,9 @@ class Orchestrator {
4477
4688
  events.onWorkerMessage(message);
4478
4689
  this.workerWs.sendAck(message.id);
4479
4690
  if (message.type === "prompt" || message.type === "task_dispatch") {
4480
- if (this.ptyManager) {
4481
- this.ptyManager.write(message.content + "\r");
4691
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
4692
+ if (activeTab) {
4693
+ activeTab.ptyManager.write(message.content + "\r");
4482
4694
  this.bridge.feedInput(message.content);
4483
4695
  }
4484
4696
  }
@@ -4491,7 +4703,8 @@ class Orchestrator {
4491
4703
  }
4492
4704
  },
4493
4705
  onTerminalInput: (data) => {
4494
- this.ptyManager?.write(data);
4706
+ const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
4707
+ activeTab?.ptyManager.write(data);
4495
4708
  }
4496
4709
  });
4497
4710
  this.bridge.setWsClient(this.workerWs);
@@ -4587,7 +4800,9 @@ class Orchestrator {
4587
4800
  name: p.name,
4588
4801
  baseUrl: p.baseUrl,
4589
4802
  hasApiKey: !!p.apiKey,
4590
- dataspacePageId: p.dataspacePageId || null
4803
+ dataspacePageId: p.dataspacePageId || null,
4804
+ trackTime: p.trackTime !== false,
4805
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
4591
4806
  }))
4592
4807
  };
4593
4808
  }
@@ -4599,7 +4814,9 @@ class Orchestrator {
4599
4814
  name: p.name,
4600
4815
  baseUrl: p.baseUrl,
4601
4816
  hasApiKey: !!p.apiKey,
4602
- dataspacePageId: p.dataspacePageId || ""
4817
+ dataspacePageId: p.dataspacePageId || "",
4818
+ trackTime: p.trackTime !== false,
4819
+ idleTimeoutMin: p.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
4603
4820
  };
4604
4821
  }
4605
4822
  saveProfile(profileId, data) {
@@ -4608,7 +4825,9 @@ class Orchestrator {
4608
4825
  name: data.name,
4609
4826
  apiKey: data.apiKey !== void 0 ? data.apiKey : existing?.apiKey || "",
4610
4827
  baseUrl: data.baseUrl || "https://app.ctlsurf.com",
4611
- dataspacePageId: data.dataspacePageId || ""
4828
+ dataspacePageId: data.dataspacePageId || "",
4829
+ trackTime: data.trackTime !== void 0 ? data.trackTime : existing?.trackTime !== false,
4830
+ idleTimeoutMin: data.idleTimeoutMin !== void 0 ? data.idleTimeoutMin : existing?.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
4612
4831
  };
4613
4832
  this.saveSettings();
4614
4833
  if (profileId === this.settings.activeProfile) {
@@ -4645,33 +4864,55 @@ class Orchestrator {
4645
4864
  this.saveSettings();
4646
4865
  return { ok: true };
4647
4866
  }
4648
- // ─── PTY & Agent ────────────────────────────────
4649
- async spawnAgent(agent, cwd) {
4650
- if (this.ptyManager) {
4651
- this.bridge.endSession();
4652
- this.ptyManager.kill();
4867
+ // ─── PTY & Agent (multi-tab) ─────────────────────
4868
+ async spawnAgent(tabId, agent, cwd, opts) {
4869
+ const existing = this.tabs.get(tabId);
4870
+ if (existing) {
4871
+ if (existing.termStreamTimer) clearTimeout(existing.termStreamTimer);
4872
+ existing.ptyManager.kill();
4873
+ this.tabs.delete(tabId);
4653
4874
  }
4654
4875
  this.currentAgent = agent;
4655
4876
  const prevCwd = this.currentCwd;
4656
4877
  this.currentCwd = cwd;
4878
+ this.activeTabId = tabId;
4657
4879
  if (prevCwd !== cwd) {
4658
4880
  this.events.onCwdChanged();
4659
4881
  }
4660
- this.ptyManager = new PtyManager(agent, cwd);
4661
- this.ptyManager.onData((data) => {
4662
- this.events.onPtyData(data);
4663
- this.bridge.feedOutput(data);
4664
- this.streamTerminalData(data);
4882
+ const ptyManager = new PtyManager(agent, cwd);
4883
+ const tab = { ptyManager, agent, cwd, termStreamBuffer: "", termStreamTimer: null };
4884
+ this.tabs.set(tabId, tab);
4885
+ ptyManager.onData((data) => {
4886
+ this.events.onPtyData(tabId, data);
4887
+ this.timeTracker.recordActivity(tabId);
4888
+ if (tabId === this.activeTabId) {
4889
+ this.bridge.feedOutput(data);
4890
+ this.streamTerminalData(tabId, data);
4891
+ }
4665
4892
  });
4666
- const thisPtyManager = this.ptyManager;
4667
- this.ptyManager.onExit(async (exitCode) => {
4668
- this.events.onPtyExit(exitCode);
4669
- this.bridge.endSession();
4670
- if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
4671
- this.workerWs.disconnect();
4893
+ ptyManager.onExit(async (exitCode) => {
4894
+ this.events.onPtyExit(tabId, exitCode);
4895
+ await this.timeTracker.endSession(tabId);
4896
+ if (tabId === this.activeTabId) {
4897
+ this.bridge.endSession();
4898
+ if (this.currentAgent && isCodingAgent(this.currentAgent)) {
4899
+ this.workerWs.disconnect();
4900
+ }
4672
4901
  }
4902
+ const t = this.tabs.get(tabId);
4903
+ if (t?.termStreamTimer) clearTimeout(t.termStreamTimer);
4673
4904
  });
4674
4905
  this.bridge.startSession();
4906
+ const profile = this.getActiveProfile();
4907
+ const shouldTrack = opts?.trackTime !== void 0 ? opts.trackTime : profile.trackTime !== false;
4908
+ if (shouldTrack) {
4909
+ void this.timeTracker.startSession(
4910
+ tabId,
4911
+ cwd,
4912
+ agent.name,
4913
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
4914
+ );
4915
+ }
4675
4916
  if (isCodingAgent(agent)) {
4676
4917
  this.connectWorkerWs(agent, cwd);
4677
4918
  } else {
@@ -4679,21 +4920,66 @@ class Orchestrator {
4679
4920
  this.checkProjectStatus(cwd);
4680
4921
  }
4681
4922
  }
4682
- writePty(data) {
4683
- this.ptyManager?.write(data);
4684
- this.bridge.feedInput(data);
4923
+ writePty(tabId, data) {
4924
+ this.tabs.get(tabId)?.ptyManager.write(data);
4925
+ if (tabId === this.activeTabId) {
4926
+ this.bridge.feedInput(data);
4927
+ }
4685
4928
  }
4686
- resizePty(cols, rows) {
4687
- this.ptyManager?.resize(cols, rows);
4688
- this.bridge.resize(cols, rows);
4689
- this.workerWs.sendTerminalResize(cols, rows);
4929
+ resizePty(tabId, cols, rows) {
4930
+ this.tabs.get(tabId)?.ptyManager.resize(cols, rows);
4931
+ if (tabId === this.activeTabId) {
4932
+ this.bridge.resize(cols, rows);
4933
+ this.workerWs.sendTerminalResize(cols, rows);
4934
+ }
4690
4935
  }
4691
- async killAgent() {
4692
- this.bridge.endSession();
4693
- this.ptyManager?.kill();
4694
- this.ptyManager = null;
4695
- if (this.currentAgent && isCodingAgent(this.currentAgent)) {
4696
- this.workerWs.disconnect();
4936
+ async killTab(tabId) {
4937
+ const tab = this.tabs.get(tabId);
4938
+ if (!tab) return;
4939
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
4940
+ await this.timeTracker.endSession(tabId);
4941
+ tab.ptyManager.kill();
4942
+ this.tabs.delete(tabId);
4943
+ if (tabId === this.activeTabId) {
4944
+ this.bridge.endSession();
4945
+ if (isCodingAgent(tab.agent)) {
4946
+ this.workerWs.disconnect();
4947
+ }
4948
+ const remaining = [...this.tabs.keys()];
4949
+ this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
4950
+ }
4951
+ }
4952
+ setActiveTab(tabId) {
4953
+ this.activeTabId = tabId;
4954
+ const tab = this.tabs.get(tabId);
4955
+ if (tab) {
4956
+ this.currentAgent = tab.agent;
4957
+ this.currentCwd = tab.cwd;
4958
+ }
4959
+ }
4960
+ getTabIds() {
4961
+ return [...this.tabs.keys()];
4962
+ }
4963
+ // ─── Tracking control (active tab) ──────────────
4964
+ isActiveTabTracking() {
4965
+ if (!this.activeTabId) return false;
4966
+ return this.timeTracker.isTracking(this.activeTabId);
4967
+ }
4968
+ async setActiveTabTracking(enabled) {
4969
+ if (!this.activeTabId) return;
4970
+ const tab = this.tabs.get(this.activeTabId);
4971
+ if (!tab) return;
4972
+ if (enabled) {
4973
+ if (this.timeTracker.isTracking(this.activeTabId)) return;
4974
+ const profile = this.getActiveProfile();
4975
+ await this.timeTracker.startSession(
4976
+ this.activeTabId,
4977
+ tab.cwd,
4978
+ tab.agent.name,
4979
+ profile.idleTimeoutMin ?? DEFAULT_IDLE_TIMEOUT_MIN
4980
+ );
4981
+ } else {
4982
+ await this.timeTracker.endSession(this.activeTabId);
4697
4983
  }
4698
4984
  }
4699
4985
  // ─── Worker WebSocket ───────────────────────────
@@ -4724,28 +5010,30 @@ class Orchestrator {
4724
5010
  this.events.onWorkerStatus("no_project");
4725
5011
  }
4726
5012
  }
4727
- streamTerminalData(data) {
4728
- this.termStreamBuffer += data;
4729
- if (!this.termStreamTimer) {
4730
- this.termStreamTimer = setTimeout(() => {
4731
- if (this.termStreamBuffer) {
4732
- this.workerWs.sendTerminalData(this.termStreamBuffer);
4733
- this.termStreamBuffer = "";
5013
+ streamTerminalData(tabId, data) {
5014
+ const tab = this.tabs.get(tabId);
5015
+ if (!tab) return;
5016
+ tab.termStreamBuffer += data;
5017
+ if (!tab.termStreamTimer) {
5018
+ tab.termStreamTimer = setTimeout(() => {
5019
+ if (tab.termStreamBuffer) {
5020
+ this.workerWs.sendTerminalData(tab.termStreamBuffer);
5021
+ tab.termStreamBuffer = "";
4734
5022
  }
4735
- this.termStreamTimer = null;
5023
+ tab.termStreamTimer = null;
4736
5024
  }, TERM_STREAM_INTERVAL_MS);
4737
5025
  }
4738
5026
  }
4739
5027
  // ─── Shutdown ───────────────────────────────────
4740
5028
  async shutdown() {
4741
5029
  this.bridge.endSession();
4742
- this.ptyManager?.kill();
4743
- this.ptyManager = null;
4744
- this.workerWs.disconnect();
4745
- if (this.termStreamTimer) {
4746
- clearTimeout(this.termStreamTimer);
4747
- this.termStreamTimer = null;
5030
+ await this.timeTracker.endAll();
5031
+ for (const [, tab] of this.tabs) {
5032
+ if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
5033
+ tab.ptyManager.kill();
4748
5034
  }
5035
+ this.tabs.clear();
5036
+ this.workerWs.disconnect();
4749
5037
  }
4750
5038
  }
4751
5039
  process.stdout?.on?.("error", () => {
@@ -4766,15 +5054,62 @@ function log(...args) {
4766
5054
  }
4767
5055
  }
4768
5056
  let mainWindow = null;
5057
+ function getProjectAbbrev(cwdPath) {
5058
+ const folderName = cwdPath.split("/").filter(Boolean).pop() || "";
5059
+ if (!folderName) return "";
5060
+ const words = folderName.replace(/([a-z])([A-Z])/g, "$1 $2").split(/[-_.\s]+/).filter(Boolean);
5061
+ if (words.length >= 2) {
5062
+ return words.slice(0, 4).map((w) => w[0]).join("").toUpperCase();
5063
+ }
5064
+ const word = words[0];
5065
+ if (word.length <= 3) return word.toUpperCase();
5066
+ return word.slice(0, 2).toUpperCase();
5067
+ }
5068
+ function createOverlayIcon(text) {
5069
+ const size = 32;
5070
+ const canvas = Buffer.alloc(size * size * 4, 0);
5071
+ for (let y = 0; y < size; y++) {
5072
+ for (let x = 0; x < size; x++) {
5073
+ const cx = x - size / 2;
5074
+ const cy = y - size / 2;
5075
+ const dist = Math.sqrt(cx * cx + cy * cy);
5076
+ if (dist <= size / 2) {
5077
+ const idx = (y * size + x) * 4;
5078
+ canvas[idx] = 122;
5079
+ canvas[idx + 1] = 162;
5080
+ canvas[idx + 2] = 247;
5081
+ canvas[idx + 3] = 220;
5082
+ }
5083
+ }
5084
+ }
5085
+ return electron.nativeImage.createFromBuffer(canvas, { width: size, height: size });
5086
+ }
5087
+ function updateProjectBadge(cwdPath) {
5088
+ if (!cwdPath) return;
5089
+ const abbrev = getProjectAbbrev(cwdPath);
5090
+ if (process.platform === "darwin" && electron.app.dock) {
5091
+ electron.app.dock.setBadge(abbrev);
5092
+ }
5093
+ if (process.platform === "win32" && mainWindow) {
5094
+ mainWindow.setOverlayIcon(createOverlayIcon(), abbrev);
5095
+ }
5096
+ if (mainWindow) {
5097
+ const folderName = cwdPath.split("/").filter(Boolean).pop() || "ctlsurf-worker";
5098
+ mainWindow.setTitle(`ctlsurf-worker — ${folderName}`);
5099
+ }
5100
+ }
4769
5101
  const orchestrator = new Orchestrator(
4770
5102
  getSettingsDir(),
4771
5103
  {
4772
- onPtyData: (data) => mainWindow?.webContents.send("pty:data", data),
4773
- onPtyExit: (code) => mainWindow?.webContents.send("pty:exit", code),
5104
+ onPtyData: (tabId, data) => mainWindow?.webContents.send("pty:data", tabId, data),
5105
+ onPtyExit: (tabId, code) => mainWindow?.webContents.send("pty:exit", tabId, code),
4774
5106
  onWorkerStatus: (status) => mainWindow?.webContents.send("worker:status", status),
4775
5107
  onWorkerMessage: (message) => mainWindow?.webContents.send("worker:message", message),
4776
5108
  onWorkerRegistered: (data) => mainWindow?.webContents.send("worker:registered", data),
4777
- onCwdChanged: () => mainWindow?.webContents.send("app:cwdChanged")
5109
+ onCwdChanged: () => {
5110
+ mainWindow?.webContents.send("app:cwdChanged");
5111
+ updateProjectBadge(orchestrator.cwd);
5112
+ }
4778
5113
  }
4779
5114
  );
4780
5115
  function createWindow() {
@@ -4802,18 +5137,21 @@ function createWindow() {
4802
5137
  mainWindow = null;
4803
5138
  });
4804
5139
  }
4805
- electron.ipcMain.handle("pty:spawn", async (_event, agent, cwd) => {
4806
- await orchestrator.spawnAgent(agent, cwd);
5140
+ electron.ipcMain.handle("pty:spawn", async (_event, tabId, agent, cwd) => {
5141
+ await orchestrator.spawnAgent(tabId, agent, cwd);
4807
5142
  return { ok: true };
4808
5143
  });
4809
- electron.ipcMain.handle("pty:write", (_event, data) => {
4810
- orchestrator.writePty(data);
5144
+ electron.ipcMain.handle("pty:write", (_event, tabId, data) => {
5145
+ orchestrator.writePty(tabId, data);
4811
5146
  });
4812
- electron.ipcMain.handle("pty:resize", (_event, cols, rows) => {
4813
- orchestrator.resizePty(cols, rows);
5147
+ electron.ipcMain.handle("pty:resize", (_event, tabId, cols, rows) => {
5148
+ orchestrator.resizePty(tabId, cols, rows);
4814
5149
  });
4815
- electron.ipcMain.handle("pty:kill", async () => {
4816
- await orchestrator.killAgent();
5150
+ electron.ipcMain.handle("pty:kill", async (_event, tabId) => {
5151
+ await orchestrator.killTab(tabId);
5152
+ });
5153
+ electron.ipcMain.handle("pty:setActiveTab", (_event, tabId) => {
5154
+ orchestrator.setActiveTab(tabId);
4817
5155
  });
4818
5156
  electron.ipcMain.handle("agents:list", () => getBuiltinAgents());
4819
5157
  electron.ipcMain.handle("agents:default", () => getDefaultAgent());
@@ -4941,6 +5279,11 @@ electron.ipcMain.handle("profiles:save", (_event, id, data) => {
4941
5279
  });
4942
5280
  electron.ipcMain.handle("profiles:switch", (_event, id) => orchestrator.switchProfile(id));
4943
5281
  electron.ipcMain.handle("profiles:delete", (_event, id) => orchestrator.deleteProfile(id));
5282
+ electron.ipcMain.handle("tracking:get", () => ({ active: orchestrator.isActiveTabTracking() }));
5283
+ electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
5284
+ await orchestrator.setActiveTabTracking(enabled);
5285
+ return { active: orchestrator.isActiveTabTracking() };
5286
+ });
4944
5287
  electron.ipcMain.handle("settings:get", (_event, key) => {
4945
5288
  const profile = orchestrator.getActiveProfile();
4946
5289
  if (key === "ctlsurfApiKey") return profile.apiKey ? "***configured***" : null;
@@ -4975,8 +5318,7 @@ electron.app.whenReady().then(() => {
4975
5318
  if (process.platform === "darwin" && electron.app.dock) {
4976
5319
  const iconPath = path.join(__dirname, "../../resources/icon.png");
4977
5320
  try {
4978
- const { nativeImage } = require("electron");
4979
- electron.app.dock.setIcon(nativeImage.createFromPath(iconPath));
5321
+ electron.app.dock.setIcon(electron.nativeImage.createFromPath(iconPath));
4980
5322
  } catch {
4981
5323
  }
4982
5324
  }