@phenx-inc/ctlsurf 0.3.8 → 0.3.10
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/bin/ctlsurf-worker.js +46 -43
- package/out/headless/index.mjs +71 -24
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +86 -45
- package/package.json +1 -1
- package/scripts/rebrand-electron.js +195 -0
- package/src/main/headless.ts +6 -0
- package/src/main/index.ts +6 -0
- package/src/main/logger.ts +10 -0
- package/src/main/orchestrator.ts +46 -4
- package/src/main/workerWs.ts +5 -8
package/out/main/index.js
CHANGED
|
@@ -9902,13 +9902,13 @@ function requireWebsocketServer() {
|
|
|
9902
9902
|
return websocketServer;
|
|
9903
9903
|
}
|
|
9904
9904
|
requireWebsocketServer();
|
|
9905
|
-
|
|
9906
|
-
function log$3(...args) {
|
|
9905
|
+
function log$2(...args) {
|
|
9907
9906
|
try {
|
|
9908
9907
|
console.log(...args);
|
|
9909
9908
|
} catch {
|
|
9910
9909
|
}
|
|
9911
9910
|
}
|
|
9911
|
+
const WS = typeof WebSocket !== "undefined" ? WebSocket : WebSocket$1;
|
|
9912
9912
|
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
9913
9913
|
const RECONNECT_DELAY_MS = 5e3;
|
|
9914
9914
|
const MAX_RECONNECT_DELAY_MS = 6e4;
|
|
@@ -9999,7 +9999,7 @@ class WorkerWsClient {
|
|
|
9999
9999
|
}
|
|
10000
10000
|
doConnect() {
|
|
10001
10001
|
if (!this.apiKey || !this.registration) {
|
|
10002
|
-
log$
|
|
10002
|
+
log$2("[worker-ws] No API key or registration, skipping connect");
|
|
10003
10003
|
return;
|
|
10004
10004
|
}
|
|
10005
10005
|
this.clearTimers();
|
|
@@ -10022,22 +10022,22 @@ class WorkerWsClient {
|
|
|
10022
10022
|
doConnectNow() {
|
|
10023
10023
|
if (!this.apiKey || !this.registration) return;
|
|
10024
10024
|
if (!this.shouldReconnect) {
|
|
10025
|
-
log$
|
|
10025
|
+
log$2("[worker-ws] shouldReconnect is false, aborting connect");
|
|
10026
10026
|
return;
|
|
10027
10027
|
}
|
|
10028
10028
|
this.setStatus("connecting");
|
|
10029
10029
|
const wsBase = this.baseUrl.replace(/^http/, "ws");
|
|
10030
10030
|
const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`;
|
|
10031
|
-
log$
|
|
10031
|
+
log$2(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
|
|
10032
10032
|
try {
|
|
10033
10033
|
this.ws = new WS(url);
|
|
10034
10034
|
} catch (err) {
|
|
10035
|
-
log$
|
|
10035
|
+
log$2("[worker-ws] Failed to create WebSocket:", err);
|
|
10036
10036
|
this.scheduleReconnect();
|
|
10037
10037
|
return;
|
|
10038
10038
|
}
|
|
10039
10039
|
this.ws.onopen = () => {
|
|
10040
|
-
log$
|
|
10040
|
+
log$2("[worker-ws] Connected, sending register");
|
|
10041
10041
|
this.reconnectDelay = RECONNECT_DELAY_MS;
|
|
10042
10042
|
this.send({
|
|
10043
10043
|
type: "register",
|
|
@@ -10050,11 +10050,11 @@ class WorkerWsClient {
|
|
|
10050
10050
|
const data = JSON.parse(String(event.data));
|
|
10051
10051
|
this.handleMessage(data);
|
|
10052
10052
|
} catch (err) {
|
|
10053
|
-
log$
|
|
10053
|
+
log$2("[worker-ws] Failed to parse message:", err);
|
|
10054
10054
|
}
|
|
10055
10055
|
};
|
|
10056
10056
|
this.ws.onclose = (event) => {
|
|
10057
|
-
log$
|
|
10057
|
+
log$2(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
|
|
10058
10058
|
this.ws = null;
|
|
10059
10059
|
this.clearHeartbeat();
|
|
10060
10060
|
this.setStatus("disconnected");
|
|
@@ -10063,7 +10063,7 @@ class WorkerWsClient {
|
|
|
10063
10063
|
}
|
|
10064
10064
|
};
|
|
10065
10065
|
this.ws.onerror = () => {
|
|
10066
|
-
log$
|
|
10066
|
+
log$2("[worker-ws] WebSocket error");
|
|
10067
10067
|
};
|
|
10068
10068
|
}
|
|
10069
10069
|
handleMessage(data) {
|
|
@@ -10072,7 +10072,7 @@ class WorkerWsClient {
|
|
|
10072
10072
|
case "registered": {
|
|
10073
10073
|
this.workerId = data.worker_id;
|
|
10074
10074
|
const workerStatus = data.status;
|
|
10075
|
-
|
|
10075
|
+
log$2(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`);
|
|
10076
10076
|
if (workerStatus === "pending_approval") {
|
|
10077
10077
|
this.setStatus("pending_approval");
|
|
10078
10078
|
} else {
|
|
@@ -10091,14 +10091,14 @@ class WorkerWsClient {
|
|
|
10091
10091
|
break;
|
|
10092
10092
|
}
|
|
10093
10093
|
case "approved": {
|
|
10094
|
-
log$
|
|
10094
|
+
log$2("[worker-ws] Worker approved!");
|
|
10095
10095
|
this.setStatus("connected");
|
|
10096
10096
|
break;
|
|
10097
10097
|
}
|
|
10098
10098
|
case "message": {
|
|
10099
10099
|
const msg = data.message;
|
|
10100
10100
|
if (msg) {
|
|
10101
|
-
|
|
10101
|
+
log$2(`[worker-ws] Received message: ${msg.id}`);
|
|
10102
10102
|
this.events.onMessage(msg);
|
|
10103
10103
|
}
|
|
10104
10104
|
break;
|
|
@@ -10113,7 +10113,7 @@ class WorkerWsClient {
|
|
|
10113
10113
|
case "heartbeat_ack":
|
|
10114
10114
|
break;
|
|
10115
10115
|
default:
|
|
10116
|
-
|
|
10116
|
+
log$2(`[worker-ws] Unknown message type: ${msgType}`);
|
|
10117
10117
|
}
|
|
10118
10118
|
}
|
|
10119
10119
|
send(data) {
|
|
@@ -10135,7 +10135,7 @@ class WorkerWsClient {
|
|
|
10135
10135
|
}
|
|
10136
10136
|
scheduleReconnect() {
|
|
10137
10137
|
if (!this.shouldReconnect) return;
|
|
10138
|
-
|
|
10138
|
+
log$2(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1e3}s...`);
|
|
10139
10139
|
this.reconnectTimer = setTimeout(() => {
|
|
10140
10140
|
this.doConnect();
|
|
10141
10141
|
}, this.reconnectDelay);
|
|
@@ -10178,7 +10178,7 @@ function isSameLocalDay(a, b2) {
|
|
|
10178
10178
|
const db = new Date(b2);
|
|
10179
10179
|
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
|
|
10180
10180
|
}
|
|
10181
|
-
function log$
|
|
10181
|
+
function log$1(...args) {
|
|
10182
10182
|
try {
|
|
10183
10183
|
console.log("[time-tracker]", ...args);
|
|
10184
10184
|
} catch {
|
|
@@ -10234,7 +10234,7 @@ class TimeTracker {
|
|
|
10234
10234
|
}
|
|
10235
10235
|
}, FIRST_CHECKPOINT_DELAY_MS);
|
|
10236
10236
|
const pending = !state.blockId || !state.rowId;
|
|
10237
|
-
log$
|
|
10237
|
+
log$1(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}${pending ? " (pending datastore — will retry on each checkpoint)" : ""}`);
|
|
10238
10238
|
}
|
|
10239
10239
|
/** Attempts to locate (or create) the datastore + add the session row.
|
|
10240
10240
|
* Returns true once the session is resolved (blockId + rowId set).
|
|
@@ -10258,15 +10258,15 @@ class TimeTracker {
|
|
|
10258
10258
|
});
|
|
10259
10259
|
const rowId = row?.id;
|
|
10260
10260
|
if (!rowId) {
|
|
10261
|
-
log$
|
|
10261
|
+
log$1(`addRow returned no id for tab=${tabId}; will retry on next checkpoint`);
|
|
10262
10262
|
return false;
|
|
10263
10263
|
}
|
|
10264
10264
|
s.blockId = blockId;
|
|
10265
10265
|
s.rowId = rowId;
|
|
10266
|
-
log$
|
|
10266
|
+
log$1(`Resolved datastore for tab=${tabId} (cwd=${s.cwd})`);
|
|
10267
10267
|
return true;
|
|
10268
10268
|
} catch (err) {
|
|
10269
|
-
log$
|
|
10269
|
+
log$1(`tryResolve failed for tab=${tabId}: ${err?.message || err}`);
|
|
10270
10270
|
return false;
|
|
10271
10271
|
}
|
|
10272
10272
|
}
|
|
@@ -10294,12 +10294,12 @@ class TimeTracker {
|
|
|
10294
10294
|
if (!s || s.ended) return;
|
|
10295
10295
|
this.rollingOver.add(tabId);
|
|
10296
10296
|
const { cwd, agentName, idleTimeoutMin } = s;
|
|
10297
|
-
log$
|
|
10297
|
+
log$1(`Day rolled over for tab=${tabId}; ending session and starting fresh`);
|
|
10298
10298
|
try {
|
|
10299
10299
|
await this.endSession(tabId);
|
|
10300
10300
|
await this.startSession(tabId, cwd, agentName, idleTimeoutMin);
|
|
10301
10301
|
} catch (err) {
|
|
10302
|
-
log$
|
|
10302
|
+
log$1(`rollover failed: ${err?.message || err}`);
|
|
10303
10303
|
} finally {
|
|
10304
10304
|
this.rollingOver.delete(tabId);
|
|
10305
10305
|
}
|
|
@@ -10316,10 +10316,10 @@ class TimeTracker {
|
|
|
10316
10316
|
if (s.blockId && s.rowId) {
|
|
10317
10317
|
await this.writeRow(s, Date.now());
|
|
10318
10318
|
} else {
|
|
10319
|
-
log$
|
|
10319
|
+
log$1(`endSession for tab=${tabId}: never resolved datastore; ${Math.round(s.activeMs / 6e4)}min not recorded`);
|
|
10320
10320
|
}
|
|
10321
10321
|
} catch (err) {
|
|
10322
|
-
log$
|
|
10322
|
+
log$1(`endSession write failed: ${err?.message || err}`);
|
|
10323
10323
|
}
|
|
10324
10324
|
s.ended = true;
|
|
10325
10325
|
this.sessions.delete(tabId);
|
|
@@ -10337,12 +10337,12 @@ class TimeTracker {
|
|
|
10337
10337
|
try {
|
|
10338
10338
|
await this.writeRow(s, Date.now());
|
|
10339
10339
|
} catch (err) {
|
|
10340
|
-
log$
|
|
10340
|
+
log$1(`checkpoint failed: ${err?.message || err}; retrying in 2s`);
|
|
10341
10341
|
setTimeout(() => {
|
|
10342
10342
|
const live = this.sessions.get(tabId);
|
|
10343
10343
|
if (!live || live.ended || !live.blockId || !live.rowId) return;
|
|
10344
10344
|
this.writeRow(live, Date.now()).catch((err2) => {
|
|
10345
|
-
log$
|
|
10345
|
+
log$1(`checkpoint retry failed: ${err2?.message || err2}`);
|
|
10346
10346
|
});
|
|
10347
10347
|
}, 2e3);
|
|
10348
10348
|
}
|
|
@@ -10381,7 +10381,7 @@ class TimeTracker {
|
|
|
10381
10381
|
props: { columns, system_key: SYSTEM_KEY }
|
|
10382
10382
|
});
|
|
10383
10383
|
if (created?.id) {
|
|
10384
|
-
log$
|
|
10384
|
+
log$1(`Created "${DATASTORE_TITLE}" datastore on Agent Datastore page for ${cwd}`);
|
|
10385
10385
|
this.blockCache.set(cwd, created.id);
|
|
10386
10386
|
return created.id;
|
|
10387
10387
|
}
|
|
@@ -10408,7 +10408,7 @@ class TimeTracker {
|
|
|
10408
10408
|
titleFallbackProps = props;
|
|
10409
10409
|
}
|
|
10410
10410
|
} catch (err) {
|
|
10411
|
-
log$
|
|
10411
|
+
log$1(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`);
|
|
10412
10412
|
}
|
|
10413
10413
|
}
|
|
10414
10414
|
if (titleFallbackId) {
|
|
@@ -10416,9 +10416,9 @@ class TimeTracker {
|
|
|
10416
10416
|
await this.api.updateBlock(titleFallbackId, {
|
|
10417
10417
|
props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY }
|
|
10418
10418
|
});
|
|
10419
|
-
log$
|
|
10419
|
+
log$1(`Backfilled system_key on legacy Time Tracking block ${titleFallbackId}`);
|
|
10420
10420
|
} catch (err) {
|
|
10421
|
-
log$
|
|
10421
|
+
log$1(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
|
|
10422
10422
|
}
|
|
10423
10423
|
return titleFallbackId;
|
|
10424
10424
|
}
|
|
@@ -10441,18 +10441,12 @@ class TimeTracker {
|
|
|
10441
10441
|
});
|
|
10442
10442
|
const merged = [...existingCols, ...appended];
|
|
10443
10443
|
await this.api.updateDatastoreSchema(blockId, merged);
|
|
10444
|
-
log$
|
|
10444
|
+
log$1(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
|
|
10445
10445
|
} catch (err) {
|
|
10446
|
-
log$
|
|
10446
|
+
log$1(`ensureColumns failed: ${err?.message || err}`);
|
|
10447
10447
|
}
|
|
10448
10448
|
}
|
|
10449
10449
|
}
|
|
10450
|
-
function log$1(...args) {
|
|
10451
|
-
try {
|
|
10452
|
-
console.log(...args);
|
|
10453
|
-
} catch {
|
|
10454
|
-
}
|
|
10455
|
-
}
|
|
10456
10450
|
const DEFAULT_IDLE_TIMEOUT_MIN = 15;
|
|
10457
10451
|
const DEFAULT_PROFILES = {
|
|
10458
10452
|
production: {
|
|
@@ -10465,6 +10459,7 @@ const DEFAULT_PROFILES = {
|
|
|
10465
10459
|
}
|
|
10466
10460
|
};
|
|
10467
10461
|
const TERM_STREAM_INTERVAL_MS = 50;
|
|
10462
|
+
const NO_PROJECT_POLL_MS = 5e3;
|
|
10468
10463
|
class Orchestrator {
|
|
10469
10464
|
settingsDir;
|
|
10470
10465
|
events;
|
|
@@ -10483,16 +10478,18 @@ class Orchestrator {
|
|
|
10483
10478
|
profiles: { ...DEFAULT_PROFILES },
|
|
10484
10479
|
logChat: false
|
|
10485
10480
|
};
|
|
10481
|
+
noProjectPollTimer = null;
|
|
10482
|
+
noProjectPollCwd = null;
|
|
10486
10483
|
constructor(settingsDir, events) {
|
|
10487
10484
|
this.settingsDir = settingsDir;
|
|
10488
10485
|
this.events = events;
|
|
10489
10486
|
this.workerWs = new WorkerWsClient({
|
|
10490
10487
|
onStatusChange: (status) => {
|
|
10491
|
-
log$
|
|
10488
|
+
log$2(`[worker-ws] Status: ${status}`);
|
|
10492
10489
|
events.onWorkerStatus(status);
|
|
10493
10490
|
},
|
|
10494
10491
|
onMessage: (message) => {
|
|
10495
|
-
log$
|
|
10492
|
+
log$2(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
|
|
10496
10493
|
events.onWorkerMessage(message);
|
|
10497
10494
|
this.workerWs.sendAck(message.id);
|
|
10498
10495
|
if (message.type === "prompt" || message.type === "task_dispatch") {
|
|
@@ -10504,10 +10501,15 @@ class Orchestrator {
|
|
|
10504
10501
|
}
|
|
10505
10502
|
},
|
|
10506
10503
|
onRegistered: (data) => {
|
|
10507
|
-
log$
|
|
10504
|
+
log$2(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
|
|
10508
10505
|
events.onWorkerRegistered(data);
|
|
10509
10506
|
if (!data.folder_id) {
|
|
10510
10507
|
events.onWorkerStatus("no_project");
|
|
10508
|
+
if (this.currentCwd && data.status !== "pending_approval") {
|
|
10509
|
+
this.startNoProjectPolling(this.currentCwd);
|
|
10510
|
+
}
|
|
10511
|
+
} else {
|
|
10512
|
+
this.stopNoProjectPolling();
|
|
10511
10513
|
}
|
|
10512
10514
|
},
|
|
10513
10515
|
onTerminalInput: (data) => {
|
|
@@ -10557,7 +10559,7 @@ class Orchestrator {
|
|
|
10557
10559
|
const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
|
|
10558
10560
|
this.ctlsurfApi.setBaseUrl(baseUrl);
|
|
10559
10561
|
this.workerWs.setBaseUrl(baseUrl);
|
|
10560
|
-
log$
|
|
10562
|
+
log$2(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
|
|
10561
10563
|
}
|
|
10562
10564
|
loadSettings() {
|
|
10563
10565
|
try {
|
|
@@ -10582,7 +10584,7 @@ class Orchestrator {
|
|
|
10582
10584
|
logChat: !!raw.logChat
|
|
10583
10585
|
};
|
|
10584
10586
|
this.saveSettings();
|
|
10585
|
-
log$
|
|
10587
|
+
log$2("[settings] Migrated legacy settings to profiles");
|
|
10586
10588
|
} else {
|
|
10587
10589
|
this.settings = raw;
|
|
10588
10590
|
if (!this.settings.profiles.production) {
|
|
@@ -10609,7 +10611,7 @@ class Orchestrator {
|
|
|
10609
10611
|
fs.mkdirSync(this.settingsDir, { recursive: true });
|
|
10610
10612
|
fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
|
|
10611
10613
|
} catch (err) {
|
|
10612
|
-
log$
|
|
10614
|
+
log$2("[settings] Failed to save:", err.message);
|
|
10613
10615
|
}
|
|
10614
10616
|
}
|
|
10615
10617
|
overrideApiKey(key) {
|
|
@@ -10747,6 +10749,7 @@ class Orchestrator {
|
|
|
10747
10749
|
if (isCodingAgent(agent)) {
|
|
10748
10750
|
this.connectWorkerWs(agent, cwd);
|
|
10749
10751
|
} else {
|
|
10752
|
+
this.stopNoProjectPolling();
|
|
10750
10753
|
this.workerWs.disconnect();
|
|
10751
10754
|
this.checkProjectStatus(cwd);
|
|
10752
10755
|
}
|
|
@@ -10818,15 +10821,50 @@ class Orchestrator {
|
|
|
10818
10821
|
const profile = this.getActiveProfile();
|
|
10819
10822
|
const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
|
|
10820
10823
|
if (!apiKey) {
|
|
10821
|
-
log$
|
|
10824
|
+
log$2("[worker-ws] No API key, skipping WS connect");
|
|
10822
10825
|
return;
|
|
10823
10826
|
}
|
|
10827
|
+
this.stopNoProjectPolling();
|
|
10824
10828
|
this.workerWs.connect({
|
|
10825
10829
|
machine: os.hostname(),
|
|
10826
10830
|
cwd,
|
|
10827
10831
|
agent: agent.name
|
|
10828
10832
|
});
|
|
10829
10833
|
}
|
|
10834
|
+
startNoProjectPolling(cwd) {
|
|
10835
|
+
if (this.noProjectPollTimer && this.noProjectPollCwd === cwd) return;
|
|
10836
|
+
this.stopNoProjectPolling();
|
|
10837
|
+
this.noProjectPollCwd = cwd;
|
|
10838
|
+
log$2(`[worker-ws] Polling for project folder at ${cwd}`);
|
|
10839
|
+
this.noProjectPollTimer = setInterval(() => {
|
|
10840
|
+
void this.checkForProjectFolder(cwd);
|
|
10841
|
+
}, NO_PROJECT_POLL_MS);
|
|
10842
|
+
}
|
|
10843
|
+
stopNoProjectPolling() {
|
|
10844
|
+
if (this.noProjectPollTimer) {
|
|
10845
|
+
clearInterval(this.noProjectPollTimer);
|
|
10846
|
+
this.noProjectPollTimer = null;
|
|
10847
|
+
this.noProjectPollCwd = null;
|
|
10848
|
+
}
|
|
10849
|
+
}
|
|
10850
|
+
async checkForProjectFolder(cwd) {
|
|
10851
|
+
if (this.currentCwd !== cwd || !this.currentAgent) {
|
|
10852
|
+
this.stopNoProjectPolling();
|
|
10853
|
+
return;
|
|
10854
|
+
}
|
|
10855
|
+
if (!this.ctlsurfApi.getApiKey()) return;
|
|
10856
|
+
try {
|
|
10857
|
+
const folder = await this.ctlsurfApi.findFolderByPath(cwd);
|
|
10858
|
+
if (folder?.id && this.currentCwd === cwd && this.currentAgent) {
|
|
10859
|
+
log$2(`[worker-ws] Project folder appeared (${folder.id}); reconnecting`);
|
|
10860
|
+
const agent = this.currentAgent;
|
|
10861
|
+
this.stopNoProjectPolling();
|
|
10862
|
+
this.workerWs.disconnect();
|
|
10863
|
+
this.connectWorkerWs(agent, cwd);
|
|
10864
|
+
}
|
|
10865
|
+
} catch {
|
|
10866
|
+
}
|
|
10867
|
+
}
|
|
10830
10868
|
async checkProjectStatus(cwd) {
|
|
10831
10869
|
if (!this.ctlsurfApi.getApiKey()) {
|
|
10832
10870
|
this.events.onWorkerStatus("no_project");
|
|
@@ -10857,6 +10895,7 @@ class Orchestrator {
|
|
|
10857
10895
|
}
|
|
10858
10896
|
// ─── Shutdown ───────────────────────────────────
|
|
10859
10897
|
async shutdown() {
|
|
10898
|
+
this.stopNoProjectPolling();
|
|
10860
10899
|
this.bridge.endSession();
|
|
10861
10900
|
await this.timeTracker.endAll();
|
|
10862
10901
|
for (const [, tab] of this.tabs) {
|
|
@@ -10867,6 +10906,8 @@ class Orchestrator {
|
|
|
10867
10906
|
this.workerWs.disconnect();
|
|
10868
10907
|
}
|
|
10869
10908
|
}
|
|
10909
|
+
electron.app.setName("ctlsurf");
|
|
10910
|
+
process.title = "ctlsurf";
|
|
10870
10911
|
process.stdout?.on?.("error", () => {
|
|
10871
10912
|
});
|
|
10872
10913
|
process.stderr?.on?.("error", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rebrand the Electron binary inside node_modules so the app shows up as
|
|
5
|
+
* "ctlsurf" (with our icon) in macOS Activity Monitor / Windows Task Manager
|
|
6
|
+
* instead of "Electron".
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: writes a sentinel file and skips if already done.
|
|
9
|
+
* Re-runs automatically after `npm update` (which wipes node_modules/electron).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { execFileSync } = require('child_process')
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
17
|
+
const PRODUCT_NAME = 'ctlsurf'
|
|
18
|
+
const ELECTRON_DIR = path.join(ROOT, 'node_modules', 'electron')
|
|
19
|
+
const SENTINEL = path.join(ELECTRON_DIR, '.ctlsurf-rebranded')
|
|
20
|
+
|
|
21
|
+
function log(msg) {
|
|
22
|
+
if (!process.env.CTLSURF_QUIET) console.log(`[ctlsurf:rebrand] ${msg}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rebrand() {
|
|
26
|
+
if (!fs.existsSync(ELECTRON_DIR)) return false
|
|
27
|
+
|
|
28
|
+
if (fs.existsSync(SENTINEL)) return true
|
|
29
|
+
|
|
30
|
+
let pathTxtPath = path.join(ELECTRON_DIR, 'path.txt')
|
|
31
|
+
if (!fs.existsSync(pathTxtPath)) return false
|
|
32
|
+
|
|
33
|
+
const relBin = fs.readFileSync(pathTxtPath, 'utf-8').trim()
|
|
34
|
+
const electronBinary = path.join(ELECTRON_DIR, relBin)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform === 'darwin') {
|
|
38
|
+
rebrandDarwin(electronBinary, pathTxtPath)
|
|
39
|
+
} else if (process.platform === 'linux') {
|
|
40
|
+
rebrandLinux(electronBinary, pathTxtPath)
|
|
41
|
+
} else if (process.platform === 'win32') {
|
|
42
|
+
rebrandWin32(electronBinary, pathTxtPath)
|
|
43
|
+
} else {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log(`failed: ${err.message}`)
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fs.writeFileSync(SENTINEL, new Date().toISOString())
|
|
52
|
+
log('done')
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── macOS ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function rebrandDarwin(electronBinary, pathTxtPath) {
|
|
59
|
+
// electronBinary = .../node_modules/electron/dist/Electron.app/Contents/MacOS/Electron
|
|
60
|
+
const macosDir = path.dirname(electronBinary)
|
|
61
|
+
const contentsDir = path.dirname(macosDir)
|
|
62
|
+
const appDir = path.dirname(contentsDir)
|
|
63
|
+
const distDir = path.dirname(appDir)
|
|
64
|
+
const newAppDir = path.join(distDir, `${PRODUCT_NAME}.app`)
|
|
65
|
+
|
|
66
|
+
// 1) Rename main executable inside MacOS/
|
|
67
|
+
const newBinaryPath = path.join(macosDir, PRODUCT_NAME)
|
|
68
|
+
if (fs.existsSync(electronBinary) && electronBinary !== newBinaryPath) {
|
|
69
|
+
fs.renameSync(electronBinary, newBinaryPath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2) Replace icon
|
|
73
|
+
const ourIcon = path.join(ROOT, 'resources', 'icon.icns')
|
|
74
|
+
const electronIcon = path.join(contentsDir, 'Resources', 'electron.icns')
|
|
75
|
+
if (fs.existsSync(ourIcon) && fs.existsSync(electronIcon)) {
|
|
76
|
+
fs.copyFileSync(ourIcon, electronIcon)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3) Patch main Info.plist
|
|
80
|
+
patchInfoPlist(path.join(contentsDir, 'Info.plist'), {
|
|
81
|
+
CFBundleName: PRODUCT_NAME,
|
|
82
|
+
CFBundleDisplayName: PRODUCT_NAME,
|
|
83
|
+
CFBundleExecutable: PRODUCT_NAME,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// 4) Rebrand helper apps so renderer/GPU subprocesses also show as "ctlsurf Helper"
|
|
87
|
+
const frameworksDir = path.join(contentsDir, 'Frameworks')
|
|
88
|
+
if (fs.existsSync(frameworksDir)) {
|
|
89
|
+
for (const entry of fs.readdirSync(frameworksDir)) {
|
|
90
|
+
if (entry.startsWith('Electron Helper') && entry.endsWith('.app')) {
|
|
91
|
+
rebrandHelper(path.join(frameworksDir, entry))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 5) Rename .app bundle directory
|
|
97
|
+
let finalAppDir = appDir
|
|
98
|
+
if (appDir !== newAppDir && !fs.existsSync(newAppDir)) {
|
|
99
|
+
fs.renameSync(appDir, newAppDir)
|
|
100
|
+
finalAppDir = newAppDir
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 6) Update path.txt to point at new bundle/binary
|
|
104
|
+
const newRel = path.join('dist', `${PRODUCT_NAME}.app`, 'Contents', 'MacOS', PRODUCT_NAME)
|
|
105
|
+
fs.writeFileSync(pathTxtPath, newRel)
|
|
106
|
+
|
|
107
|
+
// 7) Re-sign ad-hoc — original signature is invalidated by our edits, and
|
|
108
|
+
// Gatekeeper / hardened-runtime macOS configs will refuse to load the
|
|
109
|
+
// helper frameworks otherwise.
|
|
110
|
+
try {
|
|
111
|
+
execFileSync('codesign', ['--remove-signature', finalAppDir], { stdio: 'ignore' })
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
try {
|
|
114
|
+
execFileSync('codesign', ['--force', '--deep', '--sign', '-', finalAppDir], { stdio: 'ignore' })
|
|
115
|
+
} catch { /* ignore — best-effort */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rebrandHelper(helperAppDir) {
|
|
119
|
+
// e.g. helperAppDir = ".../Electron Helper (Renderer).app"
|
|
120
|
+
const oldName = path.basename(helperAppDir, '.app') // "Electron Helper (Renderer)"
|
|
121
|
+
const suffix = oldName.replace(/^Electron Helper/, '').trim() // "(Renderer)" or ""
|
|
122
|
+
const newName = suffix ? `${PRODUCT_NAME} Helper ${suffix}` : `${PRODUCT_NAME} Helper`
|
|
123
|
+
|
|
124
|
+
const macosDir = path.join(helperAppDir, 'Contents', 'MacOS')
|
|
125
|
+
const oldBinary = path.join(macosDir, oldName)
|
|
126
|
+
const newBinary = path.join(macosDir, newName)
|
|
127
|
+
if (fs.existsSync(oldBinary) && oldBinary !== newBinary) {
|
|
128
|
+
fs.renameSync(oldBinary, newBinary)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
patchInfoPlist(path.join(helperAppDir, 'Contents', 'Info.plist'), {
|
|
132
|
+
CFBundleName: newName,
|
|
133
|
+
CFBundleDisplayName: newName,
|
|
134
|
+
CFBundleExecutable: newName,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const newAppDir = path.join(path.dirname(helperAppDir), `${newName}.app`)
|
|
138
|
+
if (helperAppDir !== newAppDir && !fs.existsSync(newAppDir)) {
|
|
139
|
+
fs.renameSync(helperAppDir, newAppDir)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function patchInfoPlist(plistPath, kv) {
|
|
144
|
+
if (!fs.existsSync(plistPath)) return
|
|
145
|
+
for (const [key, val] of Object.entries(kv)) {
|
|
146
|
+
try {
|
|
147
|
+
execFileSync('plutil', ['-replace', key, '-string', val, plistPath], { stdio: 'ignore' })
|
|
148
|
+
} catch {
|
|
149
|
+
try {
|
|
150
|
+
execFileSync('plutil', ['-insert', key, '-string', val, plistPath], { stdio: 'ignore' })
|
|
151
|
+
} catch { /* ignore */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Linux ──────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function rebrandLinux(electronBinary, pathTxtPath) {
|
|
159
|
+
// dist/electron → dist/ctlsurf (process title comes from argv[0]/comm on linux)
|
|
160
|
+
const dir = path.dirname(electronBinary)
|
|
161
|
+
const newBinary = path.join(dir, PRODUCT_NAME)
|
|
162
|
+
if (fs.existsSync(electronBinary) && !fs.existsSync(newBinary)) {
|
|
163
|
+
fs.renameSync(electronBinary, newBinary)
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(pathTxtPath, path.join('dist', PRODUCT_NAME))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Windows ────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function rebrandWin32(electronBinary, pathTxtPath) {
|
|
171
|
+
// dist/electron.exe → dist/ctlsurf.exe
|
|
172
|
+
// (Embedding our icon into the exe resource table requires rcedit; out of
|
|
173
|
+
// scope for npm-install distribution. Renaming gives correct Task Manager
|
|
174
|
+
// process name; icon in tray/window comes from BrowserWindow.icon.)
|
|
175
|
+
const dir = path.dirname(electronBinary)
|
|
176
|
+
const newBinary = path.join(dir, `${PRODUCT_NAME}.exe`)
|
|
177
|
+
if (fs.existsSync(electronBinary) && !fs.existsSync(newBinary)) {
|
|
178
|
+
fs.renameSync(electronBinary, newBinary)
|
|
179
|
+
}
|
|
180
|
+
fs.writeFileSync(pathTxtPath, path.join('dist', `${PRODUCT_NAME}.exe`))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Entry ──────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
if (require.main === module) {
|
|
186
|
+
const ok = rebrand()
|
|
187
|
+
if (!ok && !fs.existsSync(ELECTRON_DIR)) {
|
|
188
|
+
// Electron not installed yet — silent no-op; launcher will rerun us
|
|
189
|
+
// after just-in-time install.
|
|
190
|
+
process.exit(0)
|
|
191
|
+
}
|
|
192
|
+
process.exit(0)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { rebrand }
|
package/src/main/headless.ts
CHANGED
|
@@ -21,6 +21,12 @@ process.on('uncaughtException', (err) => {
|
|
|
21
21
|
try { console.error('[uncaught]', err) } catch { /* ignore */ }
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
import { setSilent } from './logger'
|
|
25
|
+
|
|
26
|
+
// PTY owns the screen in TUI mode — silence background logs so they don't
|
|
27
|
+
// corrupt the agent's display.
|
|
28
|
+
setSilent(true)
|
|
29
|
+
|
|
24
30
|
import { Orchestrator } from './orchestrator'
|
|
25
31
|
import { getSettingsDir } from './settingsDir'
|
|
26
32
|
import { getBuiltinAgents, isCodingAgent, type AgentConfig } from './agents'
|
package/src/main/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ import fs from 'fs'
|
|
|
4
4
|
|
|
5
5
|
import { fetchLatestNpmVersion, compareSemver } from './updateCheck'
|
|
6
6
|
|
|
7
|
+
// Set app name early — drives the macOS menu bar title, dock label, and the
|
|
8
|
+
// CFBundleName fallback. The Activity Monitor process name is set by the
|
|
9
|
+
// rebranded Electron.app bundle (see scripts/rebrand-electron.js).
|
|
10
|
+
app.setName('ctlsurf')
|
|
11
|
+
process.title = 'ctlsurf'
|
|
12
|
+
|
|
7
13
|
// Prevent EPIPE crashes when stdout pipe is closed
|
|
8
14
|
process.stdout?.on?.('error', () => {})
|
|
9
15
|
process.stderr?.on?.('error', () => {})
|