@phenx-inc/ctlsurf 0.3.13 → 0.3.15

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/bin/ctlsurf-worker.js +38 -22
  2. package/out/headless/index.mjs +295 -3
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +351 -48
  5. package/out/preload/index.js +11 -0
  6. package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-D5dPwEy5.js} +3 -3
  7. package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-c5jJjQ9s.js} +1 -1
  8. package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-BTbmOxx9.js} +1 -1
  9. package/out/renderer/assets/{html-D1-cXoLy.js → html-3cIIQcxO.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DYbpW1yY.js} +3 -3
  11. package/out/renderer/assets/{index-65hyKM_8.css → index-6KvOnYL1.css} +404 -0
  12. package/out/renderer/assets/{index-D23nru43.js → index-D2MUZin7.js} +332 -23
  13. package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CDuCMm-6.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-COLqbq0s.js} +3 -3
  15. package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BFcqZizB.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CbkEcL-z.js} +1 -1
  17. package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-DyK93oEE.js} +1 -1
  18. package/out/renderer/assets/{python-B_dPzjJ6.js → python-D4lCwSVr.js} +1 -1
  19. package/out/renderer/assets/{razor-CHheM4ot.js → razor-DdkE9XVt.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-BrQ4Fsc-.js} +1 -1
  21. package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-BakbYMnC.js} +1 -1
  22. package/out/renderer/assets/{xml-CpS-pOPE.js → xml-DHDW9Xhp.js} +1 -1
  23. package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-1Ayv_J3q.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/main/agents.ts +36 -1
  27. package/src/main/ctlsurfApi.ts +11 -0
  28. package/src/main/headless.ts +5 -3
  29. package/src/main/index.ts +24 -2
  30. package/src/main/orchestrator.ts +66 -0
  31. package/src/main/ticketStore.ts +252 -0
  32. package/src/preload/index.ts +17 -0
  33. package/src/renderer/App.tsx +40 -1
  34. package/src/renderer/components/TicketPanel.tsx +308 -0
  35. package/src/renderer/styles.css +404 -0
package/out/main/index.js CHANGED
@@ -59,6 +59,21 @@ function getShellCommand() {
59
59
  if (process.platform === "win32") return "powershell.exe";
60
60
  return process.env.SHELL || "/bin/zsh";
61
61
  }
62
+ function isCommandAvailable(command) {
63
+ const dirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
64
+ const isWin = process.platform === "win32";
65
+ const exts = isWin ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""];
66
+ for (const dir of dirs) {
67
+ for (const ext of exts) {
68
+ try {
69
+ fs.accessSync(path.join(dir, command + ext), isWin ? fs.constants.F_OK : fs.constants.X_OK);
70
+ return true;
71
+ } catch {
72
+ }
73
+ }
74
+ }
75
+ return false;
76
+ }
62
77
  function getBuiltinAgents() {
63
78
  return [
64
79
  {
@@ -85,8 +100,14 @@ function getBuiltinAgents() {
85
100
  }
86
101
  ];
87
102
  }
103
+ function getAvailableAgents() {
104
+ const all = getBuiltinAgents();
105
+ const coding = all.filter((a) => isCodingAgent(a) && isCommandAvailable(a.command));
106
+ const shell = all.filter((a) => !isCodingAgent(a));
107
+ return [...coding, ...shell];
108
+ }
88
109
  function getDefaultAgent() {
89
- return getBuiltinAgents()[0];
110
+ return getAvailableAgents()[0];
90
111
  }
91
112
  function isCodingAgent(agent) {
92
113
  return agent.id !== "shell";
@@ -230,6 +251,14 @@ class CtlsurfApi {
230
251
  async updateRow(blockId, rowId, data) {
231
252
  return this.request("PUT", `/datastore/${blockId}/rows/${rowId}`, { data });
232
253
  }
254
+ async queryRows(blockId, opts) {
255
+ const params = new URLSearchParams();
256
+ if (opts?.orderBy) params.set("order_by", opts.orderBy);
257
+ if (opts?.order) params.set("order", opts.order);
258
+ params.set("limit", String(opts?.limit ?? 200));
259
+ const qs = params.toString();
260
+ return this.request("GET", `/datastore/${blockId}/rows${qs ? `?${qs}` : ""}`);
261
+ }
233
262
  async getDatastoreSchema(blockId) {
234
263
  return this.request("GET", `/datastore/${blockId}/schema`);
235
264
  }
@@ -9902,7 +9931,7 @@ function requireWebsocketServer() {
9902
9931
  return websocketServer;
9903
9932
  }
9904
9933
  requireWebsocketServer();
9905
- function log$2(...args) {
9934
+ function log$3(...args) {
9906
9935
  try {
9907
9936
  console.log(...args);
9908
9937
  } catch {
@@ -9999,7 +10028,7 @@ class WorkerWsClient {
9999
10028
  }
10000
10029
  doConnect() {
10001
10030
  if (!this.apiKey || !this.registration) {
10002
- log$2("[worker-ws] No API key or registration, skipping connect");
10031
+ log$3("[worker-ws] No API key or registration, skipping connect");
10003
10032
  return;
10004
10033
  }
10005
10034
  this.clearTimers();
@@ -10022,22 +10051,22 @@ class WorkerWsClient {
10022
10051
  doConnectNow() {
10023
10052
  if (!this.apiKey || !this.registration) return;
10024
10053
  if (!this.shouldReconnect) {
10025
- log$2("[worker-ws] shouldReconnect is false, aborting connect");
10054
+ log$3("[worker-ws] shouldReconnect is false, aborting connect");
10026
10055
  return;
10027
10056
  }
10028
10057
  this.setStatus("connecting");
10029
10058
  const wsBase = this.baseUrl.replace(/^http/, "ws");
10030
10059
  const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`;
10031
- log$2(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
10060
+ log$3(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
10032
10061
  try {
10033
10062
  this.ws = new WS(url);
10034
10063
  } catch (err) {
10035
- log$2("[worker-ws] Failed to create WebSocket:", err);
10064
+ log$3("[worker-ws] Failed to create WebSocket:", err);
10036
10065
  this.scheduleReconnect();
10037
10066
  return;
10038
10067
  }
10039
10068
  this.ws.onopen = () => {
10040
- log$2("[worker-ws] Connected, sending register");
10069
+ log$3("[worker-ws] Connected, sending register");
10041
10070
  this.reconnectDelay = RECONNECT_DELAY_MS;
10042
10071
  this.send({
10043
10072
  type: "register",
@@ -10050,11 +10079,11 @@ class WorkerWsClient {
10050
10079
  const data = JSON.parse(String(event.data));
10051
10080
  this.handleMessage(data);
10052
10081
  } catch (err) {
10053
- log$2("[worker-ws] Failed to parse message:", err);
10082
+ log$3("[worker-ws] Failed to parse message:", err);
10054
10083
  }
10055
10084
  };
10056
10085
  this.ws.onclose = (event) => {
10057
- log$2(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
10086
+ log$3(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
10058
10087
  this.ws = null;
10059
10088
  this.clearHeartbeat();
10060
10089
  this.setStatus("disconnected");
@@ -10063,7 +10092,7 @@ class WorkerWsClient {
10063
10092
  }
10064
10093
  };
10065
10094
  this.ws.onerror = () => {
10066
- log$2("[worker-ws] WebSocket error");
10095
+ log$3("[worker-ws] WebSocket error");
10067
10096
  };
10068
10097
  }
10069
10098
  handleMessage(data) {
@@ -10072,7 +10101,7 @@ class WorkerWsClient {
10072
10101
  case "registered": {
10073
10102
  this.workerId = data.worker_id;
10074
10103
  const workerStatus = data.status;
10075
- log$2(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`);
10104
+ log$3(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`);
10076
10105
  if (workerStatus === "pending_approval") {
10077
10106
  this.setStatus("pending_approval");
10078
10107
  } else {
@@ -10091,14 +10120,14 @@ class WorkerWsClient {
10091
10120
  break;
10092
10121
  }
10093
10122
  case "approved": {
10094
- log$2("[worker-ws] Worker approved!");
10123
+ log$3("[worker-ws] Worker approved!");
10095
10124
  this.setStatus("connected");
10096
10125
  break;
10097
10126
  }
10098
10127
  case "message": {
10099
10128
  const msg = data.message;
10100
10129
  if (msg) {
10101
- log$2(`[worker-ws] Received message: ${msg.id}`);
10130
+ log$3(`[worker-ws] Received message: ${msg.id}`);
10102
10131
  this.events.onMessage(msg);
10103
10132
  }
10104
10133
  break;
@@ -10113,7 +10142,7 @@ class WorkerWsClient {
10113
10142
  case "heartbeat_ack":
10114
10143
  break;
10115
10144
  default:
10116
- log$2(`[worker-ws] Unknown message type: ${msgType}`);
10145
+ log$3(`[worker-ws] Unknown message type: ${msgType}`);
10117
10146
  }
10118
10147
  }
10119
10148
  send(data) {
@@ -10135,7 +10164,7 @@ class WorkerWsClient {
10135
10164
  }
10136
10165
  scheduleReconnect() {
10137
10166
  if (!this.shouldReconnect) return;
10138
- log$2(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1e3}s...`);
10167
+ log$3(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1e3}s...`);
10139
10168
  this.reconnectTimer = setTimeout(() => {
10140
10169
  this.doConnect();
10141
10170
  }, this.reconnectDelay);
@@ -10149,12 +10178,12 @@ class WorkerWsClient {
10149
10178
  }
10150
10179
  }
10151
10180
  }
10152
- const DATASTORE_TITLE = "Time Tracking";
10153
- const AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
10154
- const SYSTEM_KEY = "time_tracking";
10181
+ const DATASTORE_TITLE$1 = "Time Tracking";
10182
+ const AGENT_DATASTORE_PAGE_TITLE$1 = "Agent Datastore";
10183
+ const SYSTEM_KEY$1 = "time_tracking";
10155
10184
  const FIRST_CHECKPOINT_DELAY_MS = 30 * 1e3;
10156
10185
  const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1e3;
10157
- const COLUMNS = [
10186
+ const COLUMNS$1 = [
10158
10187
  { name: "Started", type: "text" },
10159
10188
  { name: "Active Time", type: "number" },
10160
10189
  { name: "Last Updated", type: "date" },
@@ -10178,17 +10207,17 @@ function isSameLocalDay(a, b2) {
10178
10207
  const db = new Date(b2);
10179
10208
  return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
10180
10209
  }
10181
- function log$1(...args) {
10210
+ function log$2(...args) {
10182
10211
  try {
10183
10212
  console.log("[time-tracker]", ...args);
10184
10213
  } catch {
10185
10214
  }
10186
10215
  }
10187
- function findPageByTitle(pages, title) {
10216
+ function findPageByTitle$1(pages, title) {
10188
10217
  for (const p2 of pages) {
10189
10218
  if (p2?.title === title) return p2;
10190
10219
  if (p2?.children?.length) {
10191
- const c = findPageByTitle(p2.children, title);
10220
+ const c = findPageByTitle$1(p2.children, title);
10192
10221
  if (c) return c;
10193
10222
  }
10194
10223
  }
@@ -10234,7 +10263,7 @@ class TimeTracker {
10234
10263
  }
10235
10264
  }, FIRST_CHECKPOINT_DELAY_MS);
10236
10265
  const pending = !state.blockId || !state.rowId;
10237
- log$1(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}${pending ? " (pending datastore — will retry on each checkpoint)" : ""}`);
10266
+ log$2(`Started tracking tab=${tabId} agent="${agentName}" cwd=${cwd}${pending ? " (pending datastore — will retry on each checkpoint)" : ""}`);
10238
10267
  }
10239
10268
  /** Attempts to locate (or create) the datastore + add the session row.
10240
10269
  * Returns true once the session is resolved (blockId + rowId set).
@@ -10258,15 +10287,15 @@ class TimeTracker {
10258
10287
  });
10259
10288
  const rowId = row?.id;
10260
10289
  if (!rowId) {
10261
- log$1(`addRow returned no id for tab=${tabId}; will retry on next checkpoint`);
10290
+ log$2(`addRow returned no id for tab=${tabId}; will retry on next checkpoint`);
10262
10291
  return false;
10263
10292
  }
10264
10293
  s.blockId = blockId;
10265
10294
  s.rowId = rowId;
10266
- log$1(`Resolved datastore for tab=${tabId} (cwd=${s.cwd})`);
10295
+ log$2(`Resolved datastore for tab=${tabId} (cwd=${s.cwd})`);
10267
10296
  return true;
10268
10297
  } catch (err) {
10269
- log$1(`tryResolve failed for tab=${tabId}: ${err?.message || err}`);
10298
+ log$2(`tryResolve failed for tab=${tabId}: ${err?.message || err}`);
10270
10299
  return false;
10271
10300
  }
10272
10301
  }
@@ -10294,12 +10323,12 @@ class TimeTracker {
10294
10323
  if (!s || s.ended) return;
10295
10324
  this.rollingOver.add(tabId);
10296
10325
  const { cwd, agentName, idleTimeoutMin } = s;
10297
- log$1(`Day rolled over for tab=${tabId}; ending session and starting fresh`);
10326
+ log$2(`Day rolled over for tab=${tabId}; ending session and starting fresh`);
10298
10327
  try {
10299
10328
  await this.endSession(tabId);
10300
10329
  await this.startSession(tabId, cwd, agentName, idleTimeoutMin);
10301
10330
  } catch (err) {
10302
- log$1(`rollover failed: ${err?.message || err}`);
10331
+ log$2(`rollover failed: ${err?.message || err}`);
10303
10332
  } finally {
10304
10333
  this.rollingOver.delete(tabId);
10305
10334
  }
@@ -10316,10 +10345,10 @@ class TimeTracker {
10316
10345
  if (s.blockId && s.rowId) {
10317
10346
  await this.writeRow(s, Date.now());
10318
10347
  } else {
10319
- log$1(`endSession for tab=${tabId}: never resolved datastore; ${Math.round(s.activeMs / 6e4)}min not recorded`);
10348
+ log$2(`endSession for tab=${tabId}: never resolved datastore; ${Math.round(s.activeMs / 6e4)}min not recorded`);
10320
10349
  }
10321
10350
  } catch (err) {
10322
- log$1(`endSession write failed: ${err?.message || err}`);
10351
+ log$2(`endSession write failed: ${err?.message || err}`);
10323
10352
  }
10324
10353
  s.ended = true;
10325
10354
  this.sessions.delete(tabId);
@@ -10337,12 +10366,12 @@ class TimeTracker {
10337
10366
  try {
10338
10367
  await this.writeRow(s, Date.now());
10339
10368
  } catch (err) {
10340
- log$1(`checkpoint failed: ${err?.message || err}; retrying in 2s`);
10369
+ log$2(`checkpoint failed: ${err?.message || err}; retrying in 2s`);
10341
10370
  setTimeout(() => {
10342
10371
  const live = this.sessions.get(tabId);
10343
10372
  if (!live || live.ended || !live.blockId || !live.rowId) return;
10344
10373
  this.writeRow(live, Date.now()).catch((err2) => {
10345
- log$1(`checkpoint retry failed: ${err2?.message || err2}`);
10374
+ log$2(`checkpoint retry failed: ${err2?.message || err2}`);
10346
10375
  });
10347
10376
  }, 2e3);
10348
10377
  }
@@ -10356,6 +10385,205 @@ class TimeTracker {
10356
10385
  });
10357
10386
  }
10358
10387
  async ensureDatastore(cwd) {
10388
+ const cached = this.blockCache.get(cwd);
10389
+ if (cached) return cached;
10390
+ let folder = null;
10391
+ try {
10392
+ folder = await this.api.findFolderByPath(cwd);
10393
+ } catch {
10394
+ return null;
10395
+ }
10396
+ if (!folder?.id) return null;
10397
+ const folderDetail = await this.api.getFolder(folder.id);
10398
+ const agentPage = findPageByTitle$1(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE$1);
10399
+ if (!agentPage?.id) return null;
10400
+ const blockId = await this.findOrAdoptBlock(agentPage.id);
10401
+ if (blockId) {
10402
+ await this.ensureColumns(blockId);
10403
+ this.blockCache.set(cwd, blockId);
10404
+ return blockId;
10405
+ }
10406
+ const columns = COLUMNS$1.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }));
10407
+ const created = await this.api.createBlock(agentPage.id, {
10408
+ type: "datastore",
10409
+ title: DATASTORE_TITLE$1,
10410
+ props: { columns, system_key: SYSTEM_KEY$1 }
10411
+ });
10412
+ if (created?.id) {
10413
+ log$2(`Created "${DATASTORE_TITLE$1}" datastore on Agent Datastore page for ${cwd}`);
10414
+ this.blockCache.set(cwd, created.id);
10415
+ return created.id;
10416
+ }
10417
+ return null;
10418
+ }
10419
+ /** Finds an existing Time Tracking block on the page. Prefers a system_key match
10420
+ * (which survives title renames). Falls back to title match for legacy blocks
10421
+ * created before system_key existed, and backfills system_key on the way out. */
10422
+ async findOrAdoptBlock(pageId) {
10423
+ const summaries = await this.api.getPageBlockSummaries(pageId) || [];
10424
+ const datastoreSummaries = summaries.filter((b2) => b2?.type === "datastore" && b2?.id);
10425
+ if (datastoreSummaries.length === 0) return null;
10426
+ let titleFallbackId = null;
10427
+ let titleFallbackProps = null;
10428
+ for (const s of datastoreSummaries) {
10429
+ try {
10430
+ const block = await this.api.getBlock(s.id);
10431
+ const props = block?.props || {};
10432
+ if (props.system_key === SYSTEM_KEY$1) {
10433
+ return s.id;
10434
+ }
10435
+ if (s.title === DATASTORE_TITLE$1 && titleFallbackId === null) {
10436
+ titleFallbackId = s.id;
10437
+ titleFallbackProps = props;
10438
+ }
10439
+ } catch (err) {
10440
+ log$2(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`);
10441
+ }
10442
+ }
10443
+ if (titleFallbackId) {
10444
+ try {
10445
+ await this.api.updateBlock(titleFallbackId, {
10446
+ props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY$1 }
10447
+ });
10448
+ log$2(`Backfilled system_key on legacy Time Tracking block ${titleFallbackId}`);
10449
+ } catch (err) {
10450
+ log$2(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
10451
+ }
10452
+ return titleFallbackId;
10453
+ }
10454
+ return null;
10455
+ }
10456
+ async ensureColumns(blockId) {
10457
+ try {
10458
+ const schema = await this.api.getDatastoreSchema(blockId);
10459
+ const existingCols = schema.columns || [];
10460
+ const existingNames = new Set(existingCols.map((c) => c.name));
10461
+ const missing = COLUMNS$1.filter((c) => !existingNames.has(c.name));
10462
+ if (missing.length === 0) return;
10463
+ const usedIds = new Set(existingCols.map((c) => c.id));
10464
+ let nextIdx = existingCols.length;
10465
+ const appended = missing.map((c) => {
10466
+ let id = `col_${nextIdx++}`;
10467
+ while (usedIds.has(id)) id = `col_${nextIdx++}`;
10468
+ usedIds.add(id);
10469
+ return { id, name: c.name, type: c.type };
10470
+ });
10471
+ const merged = [...existingCols, ...appended];
10472
+ await this.api.updateDatastoreSchema(blockId, merged);
10473
+ log$2(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
10474
+ } catch (err) {
10475
+ log$2(`ensureColumns failed: ${err?.message || err}`);
10476
+ }
10477
+ }
10478
+ }
10479
+ const DATASTORE_TITLE = "Tickets";
10480
+ const AGENT_DATASTORE_PAGE_TITLE = "Agent Datastore";
10481
+ const SYSTEM_KEY = "tickets";
10482
+ const STATUS_OPTIONS = [
10483
+ { value: "Open", color: "blue" },
10484
+ { value: "In Progress", color: "yellow" },
10485
+ { value: "Blocked", color: "red" },
10486
+ { value: "Done", color: "green" }
10487
+ ];
10488
+ const PRIORITY_OPTIONS = [
10489
+ { value: "Low", color: "gray" },
10490
+ { value: "Med", color: "yellow" },
10491
+ { value: "High", color: "red" }
10492
+ ];
10493
+ const COLUMNS = [
10494
+ { name: "Title", type: "text" },
10495
+ { name: "Description", type: "text" },
10496
+ { name: "Status", type: "select", options: STATUS_OPTIONS },
10497
+ { name: "Priority", type: "select", options: PRIORITY_OPTIONS },
10498
+ { name: "Created", type: "date" }
10499
+ ];
10500
+ function log$1(...args) {
10501
+ try {
10502
+ console.log("[ticket-store]", ...args);
10503
+ } catch {
10504
+ }
10505
+ }
10506
+ function findPageByTitle(pages, title) {
10507
+ for (const p2 of pages) {
10508
+ if (p2?.title === title) return p2;
10509
+ if (p2?.children?.length) {
10510
+ const c = findPageByTitle(p2.children, title);
10511
+ if (c) return c;
10512
+ }
10513
+ }
10514
+ return null;
10515
+ }
10516
+ class TicketStore {
10517
+ api;
10518
+ blockCache = /* @__PURE__ */ new Map();
10519
+ constructor(api) {
10520
+ this.api = api;
10521
+ }
10522
+ /** Resolves (or creates) the project's Tickets datastore and appends a row. */
10523
+ async addTicket(cwd, input) {
10524
+ const title = input.title?.trim();
10525
+ if (!title) return { ok: false, error: "Title is required" };
10526
+ try {
10527
+ const blockId = await this.ensureDatastore(cwd, true);
10528
+ if (!blockId) {
10529
+ return { ok: false, error: "No ctlsurf project found for this folder" };
10530
+ }
10531
+ await this.api.addRow(blockId, {
10532
+ Title: title,
10533
+ Description: input.description?.trim() || "",
10534
+ Status: input.status || "Open",
10535
+ Priority: input.priority || "Med",
10536
+ Created: (/* @__PURE__ */ new Date()).toISOString()
10537
+ });
10538
+ log$1(`Added ticket "${title}" for ${cwd}`);
10539
+ return { ok: true };
10540
+ } catch (err) {
10541
+ log$1(`addTicket failed for ${cwd}: ${err?.message || err}`);
10542
+ return { ok: false, error: err?.message || String(err) };
10543
+ }
10544
+ }
10545
+ /** Updates an existing ticket row in the project's Tickets datastore. */
10546
+ async updateTicket(cwd, rowId, input) {
10547
+ const title = input.title?.trim();
10548
+ if (!title) return { ok: false, error: "Title is required" };
10549
+ try {
10550
+ const blockId = await this.ensureDatastore(cwd, false);
10551
+ if (!blockId) return { ok: false, error: "No Tickets datastore for this project" };
10552
+ await this.api.updateRow(blockId, rowId, {
10553
+ Title: title,
10554
+ Description: input.description?.trim() || "",
10555
+ Status: input.status || "Open",
10556
+ Priority: input.priority || "Med"
10557
+ });
10558
+ log$1(`Updated ticket ${rowId} for ${cwd}`);
10559
+ return { ok: true };
10560
+ } catch (err) {
10561
+ log$1(`updateTicket failed for ${cwd}: ${err?.message || err}`);
10562
+ return { ok: false, error: err?.message || String(err) };
10563
+ }
10564
+ }
10565
+ /** Lists existing tickets for the project, newest first. Does not create the
10566
+ * datastore — an unconfigured project simply has no tickets yet. */
10567
+ async listTickets(cwd) {
10568
+ try {
10569
+ const blockId = await this.ensureDatastore(cwd, false);
10570
+ if (!blockId) return { ok: true, tickets: [] };
10571
+ const res = await this.api.queryRows(blockId, { orderBy: "Created", order: "desc", limit: 200 });
10572
+ const tickets = (res?.rows || []).map((r) => ({
10573
+ id: r.id,
10574
+ title: String(r.data?.Title ?? ""),
10575
+ description: String(r.data?.Description ?? ""),
10576
+ status: String(r.data?.Status ?? "Open"),
10577
+ priority: String(r.data?.Priority ?? "Med"),
10578
+ created: r.data?.Created ?? r.created_at ?? null
10579
+ }));
10580
+ return { ok: true, tickets };
10581
+ } catch (err) {
10582
+ log$1(`listTickets failed for ${cwd}: ${err?.message || err}`);
10583
+ return { ok: false, tickets: [], error: err?.message || String(err) };
10584
+ }
10585
+ }
10586
+ async ensureDatastore(cwd, create) {
10359
10587
  const cached = this.blockCache.get(cwd);
10360
10588
  if (cached) return cached;
10361
10589
  let folder = null;
@@ -10374,7 +10602,13 @@ class TimeTracker {
10374
10602
  this.blockCache.set(cwd, blockId);
10375
10603
  return blockId;
10376
10604
  }
10377
- const columns = COLUMNS.map((c, i) => ({ id: `col_${i}`, name: c.name, type: c.type }));
10605
+ if (!create) return null;
10606
+ const columns = COLUMNS.map((c, i) => ({
10607
+ id: `col_${i}`,
10608
+ name: c.name,
10609
+ type: c.type,
10610
+ ...c.options ? { options: c.options } : {}
10611
+ }));
10378
10612
  const created = await this.api.createBlock(agentPage.id, {
10379
10613
  type: "datastore",
10380
10614
  title: DATASTORE_TITLE,
@@ -10387,9 +10621,9 @@ class TimeTracker {
10387
10621
  }
10388
10622
  return null;
10389
10623
  }
10390
- /** Finds an existing Time Tracking block on the page. Prefers a system_key match
10624
+ /** Finds an existing Tickets block on the page. Prefers a system_key match
10391
10625
  * (which survives title renames). Falls back to title match for legacy blocks
10392
- * created before system_key existed, and backfills system_key on the way out. */
10626
+ * and backfills system_key on the way out. */
10393
10627
  async findOrAdoptBlock(pageId) {
10394
10628
  const summaries = await this.api.getPageBlockSummaries(pageId) || [];
10395
10629
  const datastoreSummaries = summaries.filter((b2) => b2?.type === "datastore" && b2?.id);
@@ -10416,7 +10650,7 @@ class TimeTracker {
10416
10650
  await this.api.updateBlock(titleFallbackId, {
10417
10651
  props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY }
10418
10652
  });
10419
- log$1(`Backfilled system_key on legacy Time Tracking block ${titleFallbackId}`);
10653
+ log$1(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`);
10420
10654
  } catch (err) {
10421
10655
  log$1(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
10422
10656
  }
@@ -10437,11 +10671,11 @@ class TimeTracker {
10437
10671
  let id = `col_${nextIdx++}`;
10438
10672
  while (usedIds.has(id)) id = `col_${nextIdx++}`;
10439
10673
  usedIds.add(id);
10440
- return { id, name: c.name, type: c.type };
10674
+ return { id, name: c.name, type: c.type, ...c.options ? { options: c.options } : {} };
10441
10675
  });
10442
10676
  const merged = [...existingCols, ...appended];
10443
10677
  await this.api.updateDatastoreSchema(blockId, merged);
10444
- log$1(`Added ${missing.length} missing column(s) to existing Time Tracking datastore: ${missing.map((c) => c.name).join(", ")}`);
10678
+ log$1(`Added ${missing.length} missing column(s) to existing Tickets datastore: ${missing.map((c) => c.name).join(", ")}`);
10445
10679
  } catch (err) {
10446
10680
  log$1(`ensureColumns failed: ${err?.message || err}`);
10447
10681
  }
@@ -10468,6 +10702,7 @@ class Orchestrator {
10468
10702
  bridge = new ConversationBridge();
10469
10703
  workerWs;
10470
10704
  timeTracker = new TimeTracker(this.ctlsurfApi);
10705
+ ticketStore = new TicketStore(this.ctlsurfApi);
10471
10706
  // State
10472
10707
  tabs = /* @__PURE__ */ new Map();
10473
10708
  activeTabId = null;
@@ -10480,16 +10715,17 @@ class Orchestrator {
10480
10715
  };
10481
10716
  noProjectPollTimer = null;
10482
10717
  noProjectPollCwd = null;
10718
+ currentProjectName = null;
10483
10719
  constructor(settingsDir, events) {
10484
10720
  this.settingsDir = settingsDir;
10485
10721
  this.events = events;
10486
10722
  this.workerWs = new WorkerWsClient({
10487
10723
  onStatusChange: (status) => {
10488
- log$2(`[worker-ws] Status: ${status}`);
10724
+ log$3(`[worker-ws] Status: ${status}`);
10489
10725
  events.onWorkerStatus(status);
10490
10726
  },
10491
10727
  onMessage: (message) => {
10492
- log$2(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
10728
+ log$3(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
10493
10729
  events.onWorkerMessage(message);
10494
10730
  this.workerWs.sendAck(message.id);
10495
10731
  if (message.type === "prompt" || message.type === "task_dispatch") {
@@ -10501,15 +10737,17 @@ class Orchestrator {
10501
10737
  }
10502
10738
  },
10503
10739
  onRegistered: (data) => {
10504
- log$2(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
10740
+ log$3(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
10505
10741
  events.onWorkerRegistered(data);
10506
10742
  if (!data.folder_id) {
10743
+ this.setProjectName(null);
10507
10744
  events.onWorkerStatus("no_project");
10508
10745
  if (this.currentCwd && data.status !== "pending_approval") {
10509
10746
  this.startNoProjectPolling(this.currentCwd);
10510
10747
  }
10511
10748
  } else {
10512
10749
  this.stopNoProjectPolling();
10750
+ this.resolveProjectName(data.folder_id);
10513
10751
  }
10514
10752
  },
10515
10753
  onTerminalInput: (data) => {
@@ -10544,6 +10782,26 @@ class Orchestrator {
10544
10782
  get cwd() {
10545
10783
  return this.currentCwd;
10546
10784
  }
10785
+ // Name of the connected ctlsurf project (folder) for the desktop header.
10786
+ get projectName() {
10787
+ return this.currentProjectName;
10788
+ }
10789
+ setProjectName(name) {
10790
+ if (this.currentProjectName === name) return;
10791
+ this.currentProjectName = name;
10792
+ this.events.onProjectChanged?.(name);
10793
+ }
10794
+ // Resolve the connected folder's human-readable name. Best-effort: a failed
10795
+ // lookup just leaves the project name unset rather than blocking anything.
10796
+ async resolveProjectName(folderId) {
10797
+ try {
10798
+ const folder = await this.ctlsurfApi.getFolder(folderId);
10799
+ const name = folder?.name ?? folder?.title;
10800
+ this.setProjectName(typeof name === "string" && name ? name : null);
10801
+ } catch (err) {
10802
+ log$3(`[worker-ws] Failed to resolve project name for folder ${folderId}: ${err}`);
10803
+ }
10804
+ }
10547
10805
  get agent() {
10548
10806
  return this.currentAgent;
10549
10807
  }
@@ -10559,7 +10817,7 @@ class Orchestrator {
10559
10817
  const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
10560
10818
  this.ctlsurfApi.setBaseUrl(baseUrl);
10561
10819
  this.workerWs.setBaseUrl(baseUrl);
10562
- log$2(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
10820
+ log$3(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
10563
10821
  }
10564
10822
  loadSettings() {
10565
10823
  try {
@@ -10584,7 +10842,7 @@ class Orchestrator {
10584
10842
  logChat: !!raw.logChat
10585
10843
  };
10586
10844
  this.saveSettings();
10587
- log$2("[settings] Migrated legacy settings to profiles");
10845
+ log$3("[settings] Migrated legacy settings to profiles");
10588
10846
  } else {
10589
10847
  this.settings = raw;
10590
10848
  if (!this.settings.profiles.production) {
@@ -10611,7 +10869,7 @@ class Orchestrator {
10611
10869
  fs.mkdirSync(this.settingsDir, { recursive: true });
10612
10870
  fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
10613
10871
  } catch (err) {
10614
- log$2("[settings] Failed to save:", err.message);
10872
+ log$3("[settings] Failed to save:", err.message);
10615
10873
  }
10616
10874
  }
10617
10875
  overrideApiKey(key) {
@@ -10816,12 +11074,42 @@ class Orchestrator {
10816
11074
  await this.timeTracker.endSession(this.activeTabId);
10817
11075
  }
10818
11076
  }
11077
+ // ─── Tickets (active tab) ───────────────────────
11078
+ /** cwd of the focused terminal tab, or null if no tab is active. */
11079
+ getActiveTabCwd() {
11080
+ if (!this.activeTabId) return null;
11081
+ return this.tabs.get(this.activeTabId)?.cwd ?? null;
11082
+ }
11083
+ async addTicketForActiveTab(input) {
11084
+ const cwd = this.getActiveTabCwd();
11085
+ if (!cwd) return { ok: false, error: "No active terminal tab" };
11086
+ if (!this.ctlsurfApi.getApiKey()) {
11087
+ return { ok: false, error: "ctlsurf API key not configured" };
11088
+ }
11089
+ return this.ticketStore.addTicket(cwd, input);
11090
+ }
11091
+ async updateTicketForActiveTab(rowId, input) {
11092
+ const cwd = this.getActiveTabCwd();
11093
+ if (!cwd) return { ok: false, error: "No active terminal tab" };
11094
+ if (!this.ctlsurfApi.getApiKey()) {
11095
+ return { ok: false, error: "ctlsurf API key not configured" };
11096
+ }
11097
+ return this.ticketStore.updateTicket(cwd, rowId, input);
11098
+ }
11099
+ async listTicketsForActiveTab() {
11100
+ const cwd = this.getActiveTabCwd();
11101
+ if (!cwd) return { ok: false, tickets: [], error: "No active terminal tab" };
11102
+ if (!this.ctlsurfApi.getApiKey()) {
11103
+ return { ok: false, tickets: [], error: "ctlsurf API key not configured" };
11104
+ }
11105
+ return this.ticketStore.listTickets(cwd);
11106
+ }
10819
11107
  // ─── Worker WebSocket ───────────────────────────
10820
11108
  connectWorkerWs(agent, cwd) {
10821
11109
  const profile = this.getActiveProfile();
10822
11110
  const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
10823
11111
  if (!apiKey) {
10824
- log$2("[worker-ws] No API key, skipping WS connect");
11112
+ log$3("[worker-ws] No API key, skipping WS connect");
10825
11113
  return;
10826
11114
  }
10827
11115
  this.stopNoProjectPolling();
@@ -10835,7 +11123,7 @@ class Orchestrator {
10835
11123
  if (this.noProjectPollTimer && this.noProjectPollCwd === cwd) return;
10836
11124
  this.stopNoProjectPolling();
10837
11125
  this.noProjectPollCwd = cwd;
10838
- log$2(`[worker-ws] Polling for project folder at ${cwd}`);
11126
+ log$3(`[worker-ws] Polling for project folder at ${cwd}`);
10839
11127
  this.noProjectPollTimer = setInterval(() => {
10840
11128
  void this.checkForProjectFolder(cwd);
10841
11129
  }, NO_PROJECT_POLL_MS);
@@ -10856,7 +11144,7 @@ class Orchestrator {
10856
11144
  try {
10857
11145
  const folder = await this.ctlsurfApi.findFolderByPath(cwd);
10858
11146
  if (folder?.id && this.currentCwd === cwd && this.currentAgent) {
10859
- log$2(`[worker-ws] Project folder appeared (${folder.id}); reconnecting`);
11147
+ log$3(`[worker-ws] Project folder appeared (${folder.id}); reconnecting`);
10860
11148
  const agent = this.currentAgent;
10861
11149
  this.stopNoProjectPolling();
10862
11150
  this.workerWs.disconnect();
@@ -10978,6 +11266,7 @@ const orchestrator = new Orchestrator(
10978
11266
  onWorkerStatus: (status) => mainWindow?.webContents.send("worker:status", status),
10979
11267
  onWorkerMessage: (message) => mainWindow?.webContents.send("worker:message", message),
10980
11268
  onWorkerRegistered: (data) => mainWindow?.webContents.send("worker:registered", data),
11269
+ onProjectChanged: (name) => mainWindow?.webContents.send("app:projectChanged", name),
10981
11270
  onCwdChanged: () => {
10982
11271
  mainWindow?.webContents.send("app:cwdChanged");
10983
11272
  updateProjectBadge(orchestrator.cwd);
@@ -11025,10 +11314,11 @@ electron.ipcMain.handle("pty:kill", async (_event, tabId) => {
11025
11314
  electron.ipcMain.handle("pty:setActiveTab", (_event, tabId) => {
11026
11315
  orchestrator.setActiveTab(tabId);
11027
11316
  });
11028
- electron.ipcMain.handle("agents:list", () => getBuiltinAgents());
11317
+ electron.ipcMain.handle("agents:list", () => getAvailableAgents());
11029
11318
  electron.ipcMain.handle("agents:default", () => getDefaultAgent());
11030
11319
  electron.ipcMain.handle("app:homePath", () => electron.app.getPath("home"));
11031
11320
  electron.ipcMain.handle("app:cwd", () => process.env.CTLSURF_WORKER_CWD || process.cwd());
11321
+ electron.ipcMain.handle("app:projectName", () => orchestrator.projectName);
11032
11322
  electron.ipcMain.handle("app:browseCwd", async () => {
11033
11323
  if (!mainWindow) return null;
11034
11324
  const result = await electron.dialog.showOpenDialog(mainWindow, {
@@ -11181,6 +11471,19 @@ electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
11181
11471
  await orchestrator.setActiveTabTracking(enabled);
11182
11472
  return { active: orchestrator.isActiveTabTracking() };
11183
11473
  });
11474
+ electron.ipcMain.handle("tickets:project", () => {
11475
+ const cwd = orchestrator.getActiveTabCwd();
11476
+ return { cwd, name: cwd ? cwd.split("/").filter(Boolean).pop() || cwd : null };
11477
+ });
11478
+ electron.ipcMain.handle("tickets:add", async (_event, input) => {
11479
+ return orchestrator.addTicketForActiveTab(input);
11480
+ });
11481
+ electron.ipcMain.handle("tickets:update", async (_event, rowId, input) => {
11482
+ return orchestrator.updateTicketForActiveTab(rowId, input);
11483
+ });
11484
+ electron.ipcMain.handle("tickets:list", async () => {
11485
+ return orchestrator.listTicketsForActiveTab();
11486
+ });
11184
11487
  electron.ipcMain.handle("settings:get", (_event, key) => {
11185
11488
  const profile = orchestrator.getActiveProfile();
11186
11489
  if (key === "ctlsurfApiKey") return profile.apiKey ? "***configured***" : null;