@phenx-inc/ctlsurf 0.3.12 → 0.3.14

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 (33) hide show
  1. package/bin/ctlsurf-worker.js +280 -3
  2. package/out/headless/index.mjs +247 -1
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +303 -46
  5. package/out/preload/index.js +5 -0
  6. package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-G_SDogBL.js} +3 -3
  7. package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-BzEus0h2.js} +1 -1
  8. package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-Et995f6O.js} +1 -1
  9. package/out/renderer/assets/{html-D1-cXoLy.js → html-D4wgKxPD.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DSxpefzL.js} +3 -3
  11. package/out/renderer/assets/{index-65hyKM_8.css → index-AQ346NMi.css} +386 -0
  12. package/out/renderer/assets/{index-D23nru43.js → index-ByJTqkiQ.js} +318 -22
  13. package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CzLoo8aq.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-BrwPy7fY.js} +3 -3
  15. package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BsfPf6YG.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CxLZ421s.js} +1 -1
  17. package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-CPvHIsAR.js} +1 -1
  18. package/out/renderer/assets/{python-B_dPzjJ6.js → python-Dr7dCUjG.js} +1 -1
  19. package/out/renderer/assets/{razor-CHheM4ot.js → razor-a7zjD7Y3.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-B7KLV2X6.js} +1 -1
  21. package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-Cjuzf37q.js} +1 -1
  22. package/out/renderer/assets/{xml-CpS-pOPE.js → xml-Yz9xINtk.js} +1 -1
  23. package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-DtKnp5J0.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/main/ctlsurfApi.ts +11 -0
  27. package/src/main/index.ts +20 -0
  28. package/src/main/orchestrator.ts +37 -0
  29. package/src/main/ticketStore.ts +252 -0
  30. package/src/preload/index.ts +10 -0
  31. package/src/renderer/App.tsx +21 -0
  32. package/src/renderer/components/TicketPanel.tsx +308 -0
  33. package/src/renderer/styles.css +386 -0
@@ -233,7 +233,16 @@ function detectMode(argv) {
233
233
 
234
234
  const mode = detectMode(args)
235
235
 
236
- if (mode === 'desktop') {
236
+ // Validate the ctlsurf API key before launching — on install/update or when
237
+ // the stored key is missing/rejected. A check failure never blocks launch.
238
+ checkApiKey(mode).catch(() => {}).then(() => launch(mode))
239
+
240
+ function launch(launchMode) {
241
+ if (launchMode === 'desktop') runDesktop()
242
+ else runTerminal()
243
+ }
244
+
245
+ function runDesktop() {
237
246
  let electronPath
238
247
  try {
239
248
  electronPath = require('electron')
@@ -278,8 +287,6 @@ if (mode === 'desktop') {
278
287
  } catch (err) {
279
288
  process.exit(err.status || 0)
280
289
  }
281
- } else {
282
- runTerminal()
283
290
  }
284
291
 
285
292
  function installElectron() {
@@ -314,3 +321,273 @@ function runTerminal() {
314
321
  process.exit(err.status || 0)
315
322
  }
316
323
  }
324
+
325
+ // ─── API key check (install/update or invalid key) ────────────────
326
+
327
+ function getPkgVersion() {
328
+ try { return require(path.join(ROOT, 'package.json')).version || '0.0.0' }
329
+ catch { return '0.0.0' }
330
+ }
331
+
332
+ // settings.json locations. Terminal and desktop modes historically use
333
+ // different dirs: src/main/settingsDir.ts gives 'ctlsurf-worker' for the
334
+ // headless path, while desktop is Electron's userData under app name
335
+ // 'ctlsurf' (see src/main/index.ts). We touch both so a key entered once
336
+ // is consistent regardless of which mode launches next.
337
+ function settingsPathForMode(m) {
338
+ const home = os.homedir()
339
+ if (m === 'desktop') {
340
+ if (process.platform === 'darwin') {
341
+ return path.join(home, 'Library', 'Application Support', 'ctlsurf', 'settings.json')
342
+ }
343
+ if (process.platform === 'win32') {
344
+ return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ctlsurf', 'settings.json')
345
+ }
346
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'ctlsurf', 'settings.json')
347
+ }
348
+ if (process.platform === 'darwin') {
349
+ return path.join(home, 'Library', 'Application Support', 'ctlsurf-worker', 'settings.json')
350
+ }
351
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'ctlsurf-worker', 'settings.json')
352
+ }
353
+
354
+ function allSettingsPaths() {
355
+ return [settingsPathForMode('terminal'), settingsPathForMode('desktop')]
356
+ }
357
+
358
+ function defaultSettings() {
359
+ return {
360
+ activeProfile: 'production',
361
+ profiles: {
362
+ production: {
363
+ name: 'Production',
364
+ apiKey: '',
365
+ baseUrl: 'https://app.ctlsurf.com',
366
+ dataspacePageId: '',
367
+ trackTime: true,
368
+ idleTimeoutMin: 15,
369
+ },
370
+ },
371
+ logChat: false,
372
+ }
373
+ }
374
+
375
+ function readSettings(p) {
376
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')) }
377
+ catch { return null }
378
+ }
379
+
380
+ function writeSettings(p, data) {
381
+ try {
382
+ fs.mkdirSync(path.dirname(p), { recursive: true })
383
+ fs.writeFileSync(p, JSON.stringify(data, null, 2))
384
+ return true
385
+ } catch { return false }
386
+ }
387
+
388
+ // Normalize to the profiles shape, mirroring the orchestrator's legacy
389
+ // migration so we never clobber a pre-profiles settings file.
390
+ function normalizeSettings(s) {
391
+ if (s && s.profiles) return s
392
+ const base = defaultSettings()
393
+ if (s) {
394
+ base.profiles.production.apiKey = s.ctlsurfApiKey || ''
395
+ base.profiles.production.baseUrl = s.ctlsurfBaseUrl || base.profiles.production.baseUrl
396
+ base.profiles.production.dataspacePageId = s.ctlsurfDataspacePageId || ''
397
+ base.logChat = !!s.logChat
398
+ }
399
+ return base
400
+ }
401
+
402
+ function activeProfile(s) {
403
+ const id = s.activeProfile || 'production'
404
+ return s.profiles[id] || s.profiles.production
405
+ }
406
+
407
+ function maskKey(k) {
408
+ if (!k) return '(none)'
409
+ if (k.length <= 12) return k.slice(0, 3) + '…'
410
+ return k.slice(0, 8) + '…' + k.slice(-3)
411
+ }
412
+
413
+ // POST JSON, resolve with the HTTP status (0 on network failure/timeout).
414
+ // Uses the http/https modules directly to avoid Node 18's experimental
415
+ // fetch warning leaking into the launcher output.
416
+ function httpPostJson(urlStr, headers, bodyObj, timeoutMs) {
417
+ return new Promise((resolve) => {
418
+ let u
419
+ try { u = new URL(urlStr) } catch { return resolve(0) }
420
+ const mod = u.protocol === 'http:' ? require('http') : require('https')
421
+ const body = JSON.stringify(bodyObj)
422
+ const req = mod.request(u, {
423
+ method: 'POST',
424
+ headers: {
425
+ 'Content-Type': 'application/json',
426
+ 'Content-Length': Buffer.byteLength(body),
427
+ ...headers,
428
+ },
429
+ timeout: timeoutMs,
430
+ }, (res) => {
431
+ res.resume()
432
+ resolve(res.statusCode || 0)
433
+ })
434
+ req.on('timeout', () => { req.destroy(); resolve(0) })
435
+ req.on('error', () => resolve(0))
436
+ req.write(body)
437
+ req.end()
438
+ })
439
+ }
440
+
441
+ // → 'ok' | 'unauthorized' | 'unreachable'
442
+ async function validateKey(key, baseUrl) {
443
+ const url = String(baseUrl).replace(/\/+$/, '') + '/api/mcp'
444
+ const status = await httpPostJson(
445
+ url,
446
+ { Authorization: `Bearer ${key}` },
447
+ { jsonrpc: '2.0', method: 'ping', id: 1 },
448
+ 10000,
449
+ )
450
+ if (status === 200) return 'ok'
451
+ if (status === 401 || status === 403) return 'unauthorized'
452
+ return 'unreachable'
453
+ }
454
+
455
+ function ask(question) {
456
+ return new Promise((resolve) => {
457
+ const readline = require('readline')
458
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
459
+ rl.question(question, (answer) => { rl.close(); resolve(answer || '') })
460
+ })
461
+ }
462
+
463
+ // Export CTLSURF_API_KEY to the OS so MCP clients pick it up. On Windows
464
+ // this is a persistent user env var (setx); elsewhere it's an export line
465
+ // in the shell rc. Either way it applies to new shells, not the current one.
466
+ function exportKeyToEnv(key) {
467
+ if (process.platform === 'win32') {
468
+ try {
469
+ execFileSync('setx', ['CTLSURF_API_KEY', key], { stdio: 'ignore' })
470
+ console.log(` ${G}✓${R} Set CTLSURF_API_KEY (user environment)`)
471
+ console.log(` ${D}Open a new terminal for the env var to apply.${R}`)
472
+ } catch {
473
+ console.log(` ${Y}!${R} Could not run setx — set CTLSURF_API_KEY manually.`)
474
+ }
475
+ return
476
+ }
477
+
478
+ const home = os.homedir()
479
+ const rc = ['.zshrc', '.bashrc', '.bash_profile']
480
+ .map((f) => path.join(home, f))
481
+ .find((f) => fs.existsSync(f))
482
+ if (!rc) return
483
+ try {
484
+ let content = fs.readFileSync(rc, 'utf-8')
485
+ content = content
486
+ .split('\n')
487
+ .filter((l) => !/^\s*export\s+CTLSURF_API_KEY=/.test(l))
488
+ .join('\n')
489
+ if (content && !content.endsWith('\n')) content += '\n'
490
+ content += `\n# ctlsurf worker API key\nexport CTLSURF_API_KEY="${key}"\n`
491
+ fs.writeFileSync(rc, content)
492
+ console.log(` ${G}✓${R} Exported CTLSURF_API_KEY in ${rc}`)
493
+ console.log(` ${D}Restart your shell or run: source ${rc}${R}`)
494
+ } catch {
495
+ console.log(` ${Y}!${R} Could not update ${rc} — set CTLSURF_API_KEY manually.`)
496
+ }
497
+ }
498
+
499
+ // Persist a key to every worker settings.json profile + the OS environment.
500
+ function persistKey(key) {
501
+ for (const p of allSettingsPaths()) {
502
+ const s = normalizeSettings(readSettings(p))
503
+ activeProfile(s).apiKey = key
504
+ writeSettings(p, s)
505
+ }
506
+ console.log(` ${G}✓${R} Saved to worker settings.`)
507
+ exportKeyToEnv(key)
508
+ console.log('')
509
+ }
510
+
511
+ // Prompt for a key, validate it, persist on success. Up to 3 attempts.
512
+ async function promptForNewKey(baseUrl, optional) {
513
+ for (let attempt = 1; attempt <= 3; attempt++) {
514
+ const entered = (await ask(` Enter ctlsurf API key: `)).trim()
515
+ if (!entered) {
516
+ console.log(optional ? ' Keeping existing key.\n' : ' Continuing without a key.\n')
517
+ return
518
+ }
519
+ process.stdout.write(` Validating… `)
520
+ const result = await validateKey(entered, baseUrl)
521
+ if (result === 'ok') {
522
+ console.log(`${G}OK${R}`)
523
+ persistKey(entered)
524
+ return
525
+ }
526
+ if (result === 'unreachable') {
527
+ console.log(`${Y}server unreachable${R}`)
528
+ console.log(` Saving anyway — the worker will retry the connection.`)
529
+ persistKey(entered)
530
+ return
531
+ }
532
+ console.log(`${Y}rejected${R}`)
533
+ if (attempt < 3) console.log(` That key was not accepted. Try again.\n`)
534
+ else console.log(` ${Y}Giving up after 3 attempts. Continuing.${R}\n`)
535
+ }
536
+ }
537
+
538
+ // Validate the stored key on launch; prompt on install/update or when the
539
+ // key is missing/invalid. Never throws — launch must not be blocked.
540
+ async function checkApiKey(m) {
541
+ if (!process.stdin.isTTY) return // no terminal — can't prompt
542
+ if (args.includes('--skip-setup')) return
543
+ if (args.includes('--api-key')) return // explicit override — trust it
544
+
545
+ const currentVersion = getPkgVersion()
546
+ const settings = normalizeSettings(readSettings(settingsPathForMode(m)))
547
+ const firstRun = !settings.lastVersion
548
+ const versionChanged = settings.lastVersion !== currentVersion
549
+
550
+ const profile = activeProfile(settings)
551
+ const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || 'https://app.ctlsurf.com'
552
+ const currentKey = profile.apiKey || process.env.CTLSURF_API_KEY || ''
553
+
554
+ let reason = null // 'missing' | 'invalid' | 'update'
555
+ if (!currentKey) {
556
+ reason = 'missing'
557
+ } else {
558
+ const result = await validateKey(currentKey, baseUrl)
559
+ if (result === 'unauthorized') reason = 'invalid'
560
+ else if (result === 'ok' && versionChanged) reason = 'update'
561
+ // 'unreachable' → offline; don't prompt, don't block launch
562
+ }
563
+
564
+ if (reason) {
565
+ console.log(`\n${B} ctlsurf${R} ${D}— API key check${R}\n`)
566
+ if (reason === 'missing') {
567
+ console.log(` No ctlsurf API key is configured.`)
568
+ console.log(` Get one from: ${B}https://app.ctlsurf.com/settings${R} ${D}(API Keys tab)${R}\n`)
569
+ await promptForNewKey(baseUrl, false)
570
+ } else if (reason === 'invalid') {
571
+ console.log(` ${Y}Your ctlsurf API key was rejected${R} ${D}(${maskKey(currentKey)})${R}.`)
572
+ console.log(` It may be revoked, expired, or for a different server.`)
573
+ console.log(` Get a new one from: ${B}https://app.ctlsurf.com/settings${R} ${D}(API Keys tab)${R}\n`)
574
+ await promptForNewKey(baseUrl, false)
575
+ } else if (reason === 'update') {
576
+ console.log(firstRun
577
+ ? ` Welcome to ctlsurf v${currentVersion}.`
578
+ : ` ctlsurf was updated to v${currentVersion}.`)
579
+ console.log(` ${G}Your API key is valid${R} ${D}(${maskKey(currentKey)})${R}.`)
580
+ const ans = (await ask(` Replace it? ${D}(y/N)${R} `)).trim()
581
+ console.log('')
582
+ if (/^y(es)?$/i.test(ans)) await promptForNewKey(baseUrl, true)
583
+ }
584
+ }
585
+
586
+ // Record the version so the install/update prompt fires only once per
587
+ // release. Written to both mode paths to avoid a re-prompt on mode switch.
588
+ for (const p of allSettingsPaths()) {
589
+ const s = normalizeSettings(readSettings(p))
590
+ s.lastVersion = currentVersion
591
+ writeSettings(p, s)
592
+ }
593
+ }
@@ -5471,7 +5471,7 @@ var require_package = __commonJS({
5471
5471
  "package.json"(exports, module) {
5472
5472
  module.exports = {
5473
5473
  name: "@phenx-inc/ctlsurf",
5474
- version: "0.3.12",
5474
+ version: "0.3.14",
5475
5475
  description: "Agent-agnostic terminal and desktop app for ctlsurf \u2014 run Claude Code, Codex, or any coding agent with live session logging and remote control",
5476
5476
  main: "out/main/index.js",
5477
5477
  bin: {
@@ -5738,6 +5738,14 @@ var CtlsurfApi = class {
5738
5738
  async updateRow(blockId, rowId, data) {
5739
5739
  return this.request("PUT", `/datastore/${blockId}/rows/${rowId}`, { data });
5740
5740
  }
5741
+ async queryRows(blockId, opts) {
5742
+ const params = new URLSearchParams();
5743
+ if (opts?.orderBy) params.set("order_by", opts.orderBy);
5744
+ if (opts?.order) params.set("order", opts.order);
5745
+ params.set("limit", String(opts?.limit ?? 200));
5746
+ const qs = params.toString();
5747
+ return this.request("GET", `/datastore/${blockId}/rows${qs ? `?${qs}` : ""}`);
5748
+ }
5741
5749
  async getDatastoreSchema(blockId) {
5742
5750
  return this.request("GET", `/datastore/${blockId}/schema`);
5743
5751
  }
@@ -6607,6 +6615,213 @@ var TimeTracker = class {
6607
6615
  }
6608
6616
  };
6609
6617
 
6618
+ // src/main/ticketStore.ts
6619
+ var DATASTORE_TITLE2 = "Tickets";
6620
+ var AGENT_DATASTORE_PAGE_TITLE2 = "Agent Datastore";
6621
+ var SYSTEM_KEY2 = "tickets";
6622
+ var STATUS_OPTIONS = [
6623
+ { value: "Open", color: "blue" },
6624
+ { value: "In Progress", color: "yellow" },
6625
+ { value: "Blocked", color: "red" },
6626
+ { value: "Done", color: "green" }
6627
+ ];
6628
+ var PRIORITY_OPTIONS = [
6629
+ { value: "Low", color: "gray" },
6630
+ { value: "Med", color: "yellow" },
6631
+ { value: "High", color: "red" }
6632
+ ];
6633
+ var COLUMNS2 = [
6634
+ { name: "Title", type: "text" },
6635
+ { name: "Description", type: "text" },
6636
+ { name: "Status", type: "select", options: STATUS_OPTIONS },
6637
+ { name: "Priority", type: "select", options: PRIORITY_OPTIONS },
6638
+ { name: "Created", type: "date" }
6639
+ ];
6640
+ function log3(...args) {
6641
+ try {
6642
+ console.log("[ticket-store]", ...args);
6643
+ } catch {
6644
+ }
6645
+ }
6646
+ function findPageByTitle2(pages, title) {
6647
+ for (const p of pages) {
6648
+ if (p?.title === title) return p;
6649
+ if (p?.children?.length) {
6650
+ const c = findPageByTitle2(p.children, title);
6651
+ if (c) return c;
6652
+ }
6653
+ }
6654
+ return null;
6655
+ }
6656
+ var TicketStore = class {
6657
+ api;
6658
+ blockCache = /* @__PURE__ */ new Map();
6659
+ constructor(api) {
6660
+ this.api = api;
6661
+ }
6662
+ /** Resolves (or creates) the project's Tickets datastore and appends a row. */
6663
+ async addTicket(cwd, input) {
6664
+ const title = input.title?.trim();
6665
+ if (!title) return { ok: false, error: "Title is required" };
6666
+ try {
6667
+ const blockId = await this.ensureDatastore(cwd, true);
6668
+ if (!blockId) {
6669
+ return { ok: false, error: "No ctlsurf project found for this folder" };
6670
+ }
6671
+ await this.api.addRow(blockId, {
6672
+ Title: title,
6673
+ Description: input.description?.trim() || "",
6674
+ Status: input.status || "Open",
6675
+ Priority: input.priority || "Med",
6676
+ Created: (/* @__PURE__ */ new Date()).toISOString()
6677
+ });
6678
+ log3(`Added ticket "${title}" for ${cwd}`);
6679
+ return { ok: true };
6680
+ } catch (err) {
6681
+ log3(`addTicket failed for ${cwd}: ${err?.message || err}`);
6682
+ return { ok: false, error: err?.message || String(err) };
6683
+ }
6684
+ }
6685
+ /** Updates an existing ticket row in the project's Tickets datastore. */
6686
+ async updateTicket(cwd, rowId, input) {
6687
+ const title = input.title?.trim();
6688
+ if (!title) return { ok: false, error: "Title is required" };
6689
+ try {
6690
+ const blockId = await this.ensureDatastore(cwd, false);
6691
+ if (!blockId) return { ok: false, error: "No Tickets datastore for this project" };
6692
+ await this.api.updateRow(blockId, rowId, {
6693
+ Title: title,
6694
+ Description: input.description?.trim() || "",
6695
+ Status: input.status || "Open",
6696
+ Priority: input.priority || "Med"
6697
+ });
6698
+ log3(`Updated ticket ${rowId} for ${cwd}`);
6699
+ return { ok: true };
6700
+ } catch (err) {
6701
+ log3(`updateTicket failed for ${cwd}: ${err?.message || err}`);
6702
+ return { ok: false, error: err?.message || String(err) };
6703
+ }
6704
+ }
6705
+ /** Lists existing tickets for the project, newest first. Does not create the
6706
+ * datastore — an unconfigured project simply has no tickets yet. */
6707
+ async listTickets(cwd) {
6708
+ try {
6709
+ const blockId = await this.ensureDatastore(cwd, false);
6710
+ if (!blockId) return { ok: true, tickets: [] };
6711
+ const res = await this.api.queryRows(blockId, { orderBy: "Created", order: "desc", limit: 200 });
6712
+ const tickets = (res?.rows || []).map((r) => ({
6713
+ id: r.id,
6714
+ title: String(r.data?.Title ?? ""),
6715
+ description: String(r.data?.Description ?? ""),
6716
+ status: String(r.data?.Status ?? "Open"),
6717
+ priority: String(r.data?.Priority ?? "Med"),
6718
+ created: r.data?.Created ?? r.created_at ?? null
6719
+ }));
6720
+ return { ok: true, tickets };
6721
+ } catch (err) {
6722
+ log3(`listTickets failed for ${cwd}: ${err?.message || err}`);
6723
+ return { ok: false, tickets: [], error: err?.message || String(err) };
6724
+ }
6725
+ }
6726
+ async ensureDatastore(cwd, create) {
6727
+ const cached = this.blockCache.get(cwd);
6728
+ if (cached) return cached;
6729
+ let folder = null;
6730
+ try {
6731
+ folder = await this.api.findFolderByPath(cwd);
6732
+ } catch {
6733
+ return null;
6734
+ }
6735
+ if (!folder?.id) return null;
6736
+ const folderDetail = await this.api.getFolder(folder.id);
6737
+ const agentPage = findPageByTitle2(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE2);
6738
+ if (!agentPage?.id) return null;
6739
+ const blockId = await this.findOrAdoptBlock(agentPage.id);
6740
+ if (blockId) {
6741
+ await this.ensureColumns(blockId);
6742
+ this.blockCache.set(cwd, blockId);
6743
+ return blockId;
6744
+ }
6745
+ if (!create) return null;
6746
+ const columns = COLUMNS2.map((c, i) => ({
6747
+ id: `col_${i}`,
6748
+ name: c.name,
6749
+ type: c.type,
6750
+ ...c.options ? { options: c.options } : {}
6751
+ }));
6752
+ const created = await this.api.createBlock(agentPage.id, {
6753
+ type: "datastore",
6754
+ title: DATASTORE_TITLE2,
6755
+ props: { columns, system_key: SYSTEM_KEY2 }
6756
+ });
6757
+ if (created?.id) {
6758
+ log3(`Created "${DATASTORE_TITLE2}" datastore on Agent Datastore page for ${cwd}`);
6759
+ this.blockCache.set(cwd, created.id);
6760
+ return created.id;
6761
+ }
6762
+ return null;
6763
+ }
6764
+ /** Finds an existing Tickets block on the page. Prefers a system_key match
6765
+ * (which survives title renames). Falls back to title match for legacy blocks
6766
+ * and backfills system_key on the way out. */
6767
+ async findOrAdoptBlock(pageId) {
6768
+ const summaries = await this.api.getPageBlockSummaries(pageId) || [];
6769
+ const datastoreSummaries = summaries.filter((b) => b?.type === "datastore" && b?.id);
6770
+ if (datastoreSummaries.length === 0) return null;
6771
+ let titleFallbackId = null;
6772
+ let titleFallbackProps = null;
6773
+ for (const s of datastoreSummaries) {
6774
+ try {
6775
+ const block = await this.api.getBlock(s.id);
6776
+ const props = block?.props || {};
6777
+ if (props.system_key === SYSTEM_KEY2) {
6778
+ return s.id;
6779
+ }
6780
+ if (s.title === DATASTORE_TITLE2 && titleFallbackId === null) {
6781
+ titleFallbackId = s.id;
6782
+ titleFallbackProps = props;
6783
+ }
6784
+ } catch (err) {
6785
+ log3(`getBlock(${s.id}) failed during lookup: ${err?.message || err}`);
6786
+ }
6787
+ }
6788
+ if (titleFallbackId) {
6789
+ try {
6790
+ await this.api.updateBlock(titleFallbackId, {
6791
+ props: { ...titleFallbackProps || {}, system_key: SYSTEM_KEY2 }
6792
+ });
6793
+ log3(`Backfilled system_key on legacy Tickets block ${titleFallbackId}`);
6794
+ } catch (err) {
6795
+ log3(`backfill system_key failed on ${titleFallbackId}: ${err?.message || err}`);
6796
+ }
6797
+ return titleFallbackId;
6798
+ }
6799
+ return null;
6800
+ }
6801
+ async ensureColumns(blockId) {
6802
+ try {
6803
+ const schema = await this.api.getDatastoreSchema(blockId);
6804
+ const existingCols = schema.columns || [];
6805
+ const existingNames = new Set(existingCols.map((c) => c.name));
6806
+ const missing = COLUMNS2.filter((c) => !existingNames.has(c.name));
6807
+ if (missing.length === 0) return;
6808
+ const usedIds = new Set(existingCols.map((c) => c.id));
6809
+ let nextIdx = existingCols.length;
6810
+ const appended = missing.map((c) => {
6811
+ let id = `col_${nextIdx++}`;
6812
+ while (usedIds.has(id)) id = `col_${nextIdx++}`;
6813
+ usedIds.add(id);
6814
+ return { id, name: c.name, type: c.type, ...c.options ? { options: c.options } : {} };
6815
+ });
6816
+ const merged = [...existingCols, ...appended];
6817
+ await this.api.updateDatastoreSchema(blockId, merged);
6818
+ log3(`Added ${missing.length} missing column(s) to existing Tickets datastore: ${missing.map((c) => c.name).join(", ")}`);
6819
+ } catch (err) {
6820
+ log3(`ensureColumns failed: ${err?.message || err}`);
6821
+ }
6822
+ }
6823
+ };
6824
+
6610
6825
  // src/main/orchestrator.ts
6611
6826
  var DEFAULT_IDLE_TIMEOUT_MIN = 15;
6612
6827
  var DEFAULT_PROFILES = {
@@ -6629,6 +6844,7 @@ var Orchestrator = class {
6629
6844
  bridge = new ConversationBridge();
6630
6845
  workerWs;
6631
6846
  timeTracker = new TimeTracker(this.ctlsurfApi);
6847
+ ticketStore = new TicketStore(this.ctlsurfApi);
6632
6848
  // State
6633
6849
  tabs = /* @__PURE__ */ new Map();
6634
6850
  activeTabId = null;
@@ -6977,6 +7193,36 @@ var Orchestrator = class {
6977
7193
  await this.timeTracker.endSession(this.activeTabId);
6978
7194
  }
6979
7195
  }
7196
+ // ─── Tickets (active tab) ───────────────────────
7197
+ /** cwd of the focused terminal tab, or null if no tab is active. */
7198
+ getActiveTabCwd() {
7199
+ if (!this.activeTabId) return null;
7200
+ return this.tabs.get(this.activeTabId)?.cwd ?? null;
7201
+ }
7202
+ async addTicketForActiveTab(input) {
7203
+ const cwd = this.getActiveTabCwd();
7204
+ if (!cwd) return { ok: false, error: "No active terminal tab" };
7205
+ if (!this.ctlsurfApi.getApiKey()) {
7206
+ return { ok: false, error: "ctlsurf API key not configured" };
7207
+ }
7208
+ return this.ticketStore.addTicket(cwd, input);
7209
+ }
7210
+ async updateTicketForActiveTab(rowId, input) {
7211
+ const cwd = this.getActiveTabCwd();
7212
+ if (!cwd) return { ok: false, error: "No active terminal tab" };
7213
+ if (!this.ctlsurfApi.getApiKey()) {
7214
+ return { ok: false, error: "ctlsurf API key not configured" };
7215
+ }
7216
+ return this.ticketStore.updateTicket(cwd, rowId, input);
7217
+ }
7218
+ async listTicketsForActiveTab() {
7219
+ const cwd = this.getActiveTabCwd();
7220
+ if (!cwd) return { ok: false, tickets: [], error: "No active terminal tab" };
7221
+ if (!this.ctlsurfApi.getApiKey()) {
7222
+ return { ok: false, tickets: [], error: "ctlsurf API key not configured" };
7223
+ }
7224
+ return this.ticketStore.listTickets(cwd);
7225
+ }
6980
7226
  // ─── Worker WebSocket ───────────────────────────
6981
7227
  connectWorkerWs(agent, cwd) {
6982
7228
  const profile = this.getActiveProfile();