@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.
- package/out/headless/index.mjs +409 -99
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +419 -77
- package/out/preload/index.js +12 -8
- package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
- package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
- package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
- package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
- package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
- package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
- package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
- package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
- package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
- package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
- package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
- package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
- package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
- package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
- package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/ctlsurfApi.ts +26 -0
- package/src/main/headless.ts +40 -34
- package/src/main/index.ts +95 -13
- package/src/main/orchestrator.ts +160 -55
- package/src/main/timeTracker.ts +223 -0
- package/src/main/tui.ts +25 -5
- package/src/preload/index.ts +23 -15
- package/src/renderer/App.tsx +197 -43
- package/src/renderer/components/SettingsDialog.tsx +38 -1
- package/src/renderer/components/TerminalPanel.tsx +109 -59
- 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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
4354
|
+
log$3("[worker-ws] Failed to parse message:", err);
|
|
4336
4355
|
}
|
|
4337
4356
|
};
|
|
4338
4357
|
this.ws.onclose = (event) => {
|
|
4339
|
-
log$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
-
|
|
4481
|
-
|
|
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.
|
|
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
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
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
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
this.
|
|
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
|
-
|
|
4667
|
-
|
|
4668
|
-
this.
|
|
4669
|
-
this.
|
|
4670
|
-
|
|
4671
|
-
this.
|
|
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
|
|
4684
|
-
this.
|
|
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
|
|
4688
|
-
this.
|
|
4689
|
-
|
|
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
|
|
4692
|
-
this.
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
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.
|
|
4729
|
-
if (!
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
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
|
-
|
|
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.
|
|
4743
|
-
this.
|
|
4744
|
-
|
|
4745
|
-
|
|
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: () =>
|
|
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.
|
|
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
|
-
|
|
4979
|
-
electron.app.dock.setIcon(nativeImage.createFromPath(iconPath));
|
|
5321
|
+
electron.app.dock.setIcon(electron.nativeImage.createFromPath(iconPath));
|
|
4980
5322
|
} catch {
|
|
4981
5323
|
}
|
|
4982
5324
|
}
|