hotsheet 0.13.1 → 0.14.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/README.md CHANGED
@@ -21,7 +21,7 @@ No cloud. No logins. No JIRA. Just tickets and a tight feedback loop.
21
21
  | Linux | `.AppImage` / `.deb` |
22
22
  | Windows | `.msi` / `.exe` |
23
23
 
24
- After installing, open the app and click **Install CLI** to add the `hotsheet` command to your PATH.
24
+ After installing, open the app and use **Open Folder** to get started — or click **Install CLI** to add the `hotsheet` command to your PATH for terminal launching.
25
25
 
26
26
  **Or install via npm:**
27
27
 
@@ -92,7 +92,7 @@ The loop stays tight because the AI always knows what to work on next.
92
92
  <img src="docs/demo-5.png" alt="Multiple tickets selected with the batch toolbar and context menu" width="900">
93
93
  </p>
94
94
 
95
- **Detail panel** — side or bottom orientation (toggle in the toolbar), resizable. Shows category, priority, status, and Up Next in a compact grid, plus title, details, tags, attachments, and editable notes. Click a note to edit inline; right-click to delete.
95
+ **Detail panel** — side or bottom orientation (toggle in the toolbar), resizable, collapsible (click the active position to hide). Shows category, priority, status, and Up Next in a compact grid, plus title, details, tags, attachments, and editable notes. Click a note to edit inline; right-click to delete.
96
96
 
97
97
  <p align="center">
98
98
  <img src="docs/demo-6.png" alt="Detail panel in bottom orientation showing ticket details, tags, and notes" width="900">
@@ -118,16 +118,16 @@ The loop stays tight because the AI always knows what to work on next.
118
118
  - **Up Next flag** — star tickets to add them to the AI worklist
119
119
  - **Drag and drop** — drag tickets onto sidebar views to change category, priority, or status; drop files onto the detail panel to attach; reorder project tabs and custom views
120
120
  - **Right-click context menus** — full context menu on tickets with category/priority/status submenus, tags, duplicate, backlog, archive, delete — all with Lucide icons
121
- - **Search** — full-text search across ticket titles, details, and ticket numbers
121
+ - **Search** — full-text search across ticket titles, details, ticket numbers, and tags
122
122
  - **Print** — print the dashboard, all tickets, selected tickets, or individual tickets in checklist, summary, or full-detail format
123
123
  - **Keyboard-driven** — `Enter` to create, `Cmd+I/B/F/R/K/G` for categories, `Alt+1-5` for priority, `Cmd+D` for Up Next, `Delete` to trash, `Cmd+P` to print, `Cmd+Z/Shift+Z` for undo/redo
124
124
  - **Undo/redo** — `Cmd+Z` and `Cmd+Shift+Z` for all operations including notes, batch changes, and deletions
125
125
  - **Animated transitions** — smooth FLIP animations when tickets reorder after property changes
126
- - **Copy for commits** — `Cmd+C` copies selected ticket info (number + title + details + notes) for use in commit messages
126
+ - **Copy / cut / paste** — `Cmd+C` copies selected tickets (formatted text to clipboard + structured data for paste), `Cmd+X` cuts, `Cmd+V` pastes into the current project with title dedup. Works across projects.
127
127
  - **File attachments** — attach files via file picker or drag-and-drop onto the detail panel, reveal in file manager
128
128
  - **Markdown sync** — `worklist.md` and `open-tickets.md` auto-generated on every change
129
129
  - **Automatic backups** — tiered snapshots (every 5 min, hourly, daily) with preview-before-restore recovery
130
- - **Auto-cleanup** — configurable auto-deletion of old trash and verified items
130
+ - **Auto-cleanup** — verified tickets auto-archive after a configurable number of days; trashed tickets auto-delete
131
131
  - **App icon variants** — 9 icon variants to choose from in Settings, applied instantly to the dock icon
132
132
  - **Fully local** — embedded PostgreSQL (PGLite), no network calls, no accounts, no telemetry
133
133
 
@@ -164,9 +164,9 @@ Hot Sheet automatically generates skill files for Claude Code (as well as Cursor
164
164
  Hot Sheet can push events directly to a running Claude Code session via MCP channels. Enable it in Settings → Experimental:
165
165
 
166
166
  - **Play button** — appears in the sidebar. Single-click sends the worklist to Claude on demand.
167
- - **Auto mode** — double-click the play button to enable automatic mode. When you star a ticket for Up Next, Claude is notified after a 5-second debounce and picks up the work automatically. Exponential backoff prevents runaway retries.
167
+ - **Auto mode** — double-click the play button to enable automatic mode. Claude is triggered immediately, then continues monitoring for new Up Next items with debounce. Exponential backoff prevents runaway retries.
168
168
  - **Auto-prioritize** — when no tickets are flagged as Up Next, Claude automatically evaluates open tickets and picks the most important ones to work on.
169
- - **Custom commands** — create named buttons that send custom prompts to Claude **or run shell commands** directly. Toggle between "Claude Code" and "Shell" targets per command. Shell commands execute server-side with stdout/stderr captured to the commands log.
169
+ - **Custom commands** — create named buttons that send custom prompts to Claude **or run shell commands** directly. Organize into collapsible groups. Toggle between "Claude Code" and "Shell" targets per command. Shell commands execute server-side with stdout/stderr captured to the commands log.
170
170
  - **Permission relay** — when Claude needs tool approval (Bash, Edit, etc.), a full-screen overlay shows the tool name and command preview with Allow/Deny/Dismiss buttons — no need to switch to the terminal.
171
171
  - **Commands log** — a resizable bottom panel that records all communication: triggers, completions, permission requests, and shell command output. Filter by type, search, and copy entries. Shell commands show a stop button for running processes.
172
172
  - **Status indicator** — shows "Claude working" / "Shell running" / idle in the footer.
@@ -256,7 +256,7 @@ Only one Hot Sheet instance can use a data directory at a time. If you accidenta
256
256
 
257
257
  Download the latest release for your platform from [GitHub Releases](https://github.com/brianwestphal/hotsheet/releases).
258
258
 
259
- On first launch, the app will prompt you to install the `hotsheet` CLI command. This creates a symlink so you can launch the desktop app from any project directory. You can also install it manually:
259
+ On first launch, use **Open Folder** to point the app at your project directory. The app remembers your projects and reopens them on subsequent launches. Optionally install the CLI for terminal-based launching:
260
260
 
261
261
  **macOS:**
262
262
  ```bash
@@ -340,6 +340,8 @@ All settings can also be changed from the settings panel UI.
340
340
  | `Cmd+D` | Toggle Up Next |
341
341
  | `Delete` / `Backspace` | Delete selected tickets |
342
342
  | `Cmd+C` | Copy ticket info |
343
+ | `Cmd+X` | Cut tickets |
344
+ | `Cmd+V` | Paste tickets |
343
345
  | `Cmd+A` | Select all |
344
346
  | `Cmd+Z` | Undo |
345
347
  | `Cmd+Shift+Z` | Redo |
@@ -381,14 +383,14 @@ npm install
381
383
  npm run dev # Build client assets, then run via tsx
382
384
  npm run build # Build to dist/cli.js
383
385
  npm test # Unit tests with coverage (446 tests)
384
- npm run test:e2e # E2E browser tests (55 tests)
386
+ npm run test:e2e # E2E browser tests (64 tests)
385
387
  npm run test:all # Merged coverage report (unit + E2E)
386
388
  npm run lint # ESLint
387
389
  npm run clean # Remove dist and caches
388
390
  npm link # Symlink for global 'hotsheet' command
389
391
  ```
390
392
 
391
- The project has comprehensive test coverage with 446 unit tests (vitest) and 55 Playwright E2E browser tests, plus 12 smoke tests for production install verification.
393
+ The project has comprehensive test coverage with 446 unit tests (vitest) and 64 Playwright E2E browser tests, plus 12 smoke tests for production install verification.
392
394
 
393
395
  ---
394
396
 
package/dist/channel.js CHANGED
@@ -33475,6 +33475,7 @@ var StdioServerTransport = class {
33475
33475
  import { readFileSync, unlinkSync, writeFileSync } from "fs";
33476
33476
  import { createServer } from "http";
33477
33477
  import { join } from "path";
33478
+ var CHANNEL_VERSION = 3;
33478
33479
  var dataDir = ".hotsheet";
33479
33480
  var args = process.argv.slice(2);
33480
33481
  for (let i = 0; i < args.length; i++) {
@@ -33498,7 +33499,8 @@ var mcp = new Server(
33498
33499
  "When you receive a channel event from hotsheet-channel, follow the instructions in the event content.",
33499
33500
  "Typically this means running /hotsheet to process the current Up Next work items.",
33500
33501
  "Do not ask for confirmation \u2014 just execute the requested action.",
33501
- "IMPORTANT: When you finish processing (or if there was nothing to process), you MUST run the curl command provided in the event to signal completion."
33502
+ "IMPORTANT: When you finish processing (or if there was nothing to process), you MUST run the curl command provided in the event to signal completion.",
33503
+ "IMPORTANT: Do NOT use the Hot Sheet API (curl commands) to read or list tickets. Always use /hotsheet to read the worklist. The API should only be used for updating ticket status and creating new tickets as documented in the worklist."
33502
33504
  ].join(" ")
33503
33505
  }
33504
33506
  );
@@ -33537,7 +33539,7 @@ var httpServer = createServer(async (req, res) => {
33537
33539
  }
33538
33540
  if (req.method === "GET" && req.url === "/health") {
33539
33541
  res.writeHead(200, { "Content-Type": "application/json" });
33540
- res.end(JSON.stringify({ ok: true }));
33542
+ res.end(JSON.stringify({ ok: true, version: CHANNEL_VERSION }));
33541
33543
  return;
33542
33544
  }
33543
33545
  if (req.method === "GET" && req.url === "/permission") {
@@ -33611,6 +33613,12 @@ var httpServer = createServer(async (req, res) => {
33611
33613
  }
33612
33614
  return;
33613
33615
  }
33616
+ if (req.method === "POST" && req.url === "/shutdown") {
33617
+ res.writeHead(200, { "Content-Type": "application/json" });
33618
+ res.end(JSON.stringify({ ok: true }));
33619
+ setTimeout(() => void cleanup(), 100);
33620
+ return;
33621
+ }
33614
33622
  res.writeHead(404);
33615
33623
  res.end("not found");
33616
33624
  });
@@ -33675,3 +33683,6 @@ process.on("exit", () => {
33675
33683
  } catch {
33676
33684
  }
33677
33685
  });
33686
+ export {
33687
+ CHANNEL_VERSION
33688
+ };
package/dist/cli.js CHANGED
@@ -143,7 +143,6 @@ async function initSchema(db) {
143
143
  INSERT INTO settings (key, value) VALUES ('detail_width', '360') ON CONFLICT DO NOTHING;
144
144
  INSERT INTO settings (key, value) VALUES ('detail_height', '300') ON CONFLICT DO NOTHING;
145
145
  INSERT INTO settings (key, value) VALUES ('trash_cleanup_days', '3') ON CONFLICT DO NOTHING;
146
- INSERT INTO settings (key, value) VALUES ('completed_cleanup_days', '30') ON CONFLICT DO NOTHING;
147
146
  INSERT INTO settings (key, value) VALUES ('verified_cleanup_days', '30') ON CONFLICT DO NOTHING;
148
147
  `);
149
148
  await db.exec(`
@@ -970,9 +969,6 @@ async function getTickets(filters = {}) {
970
969
  case "status":
971
970
  orderBy = STATUS_ORD;
972
971
  break;
973
- case "ticket_number":
974
- orderBy = "id";
975
- break;
976
972
  case "created":
977
973
  case void 0:
978
974
  orderBy = "created_at";
@@ -1024,7 +1020,11 @@ async function batchDeleteTickets(ids) {
1024
1020
  async function toggleUpNext(id) {
1025
1021
  const db = await getDb();
1026
1022
  const result = await db.query(
1027
- `UPDATE tickets SET up_next = NOT up_next, updated_at = NOW() WHERE id = $1 RETURNING *`,
1023
+ `UPDATE tickets SET up_next = NOT up_next, updated_at = NOW(),
1024
+ status = CASE WHEN NOT up_next AND status IN ('completed', 'verified') THEN 'not_started' ELSE status END,
1025
+ completed_at = CASE WHEN NOT up_next AND status IN ('completed', 'verified') THEN NULL ELSE completed_at END,
1026
+ verified_at = CASE WHEN NOT up_next AND status IN ('completed', 'verified') THEN NULL ELSE verified_at END
1027
+ WHERE id = $1 RETURNING *`,
1028
1028
  [id]
1029
1029
  );
1030
1030
  return result.rows[0] ?? null;
@@ -1623,7 +1623,7 @@ function mainSkillBody(projectRoot2) {
1623
1623
  ].join("\n");
1624
1624
  }
1625
1625
  function ensureClaudePermissions(cwd) {
1626
- if (skillPort < 4170 || skillPort > 4189) return false;
1626
+ if (skillPort < 4170 || skillPort > 4199) return false;
1627
1627
  const settingsPath2 = join5(cwd, ".claude", "settings.json");
1628
1628
  let settings = {};
1629
1629
  if (existsSync4(settingsPath2)) {
@@ -1802,9 +1802,10 @@ var init_skills = __esm({
1802
1802
  skillCategories = DEFAULT_CATEGORIES;
1803
1803
  HOTSHEET_ALLOW_PATTERNS = [
1804
1804
  "Bash(curl * http://localhost:417*/api/*)",
1805
- "Bash(curl * http://localhost:418*/api/*)"
1805
+ "Bash(curl * http://localhost:418*/api/*)",
1806
+ "Bash(curl * http://localhost:419*/api/*)"
1806
1807
  ];
1807
- HOTSHEET_CURL_RE = /^Bash\(curl \* http:\/\/localhost:\d+\/api\/\*\)$|^Bash\(curl \* http:\/\/localhost:41[78]\*\/api\/\*\)$/;
1808
+ HOTSHEET_CURL_RE = /^Bash\(curl \* http:\/\/localhost:\d+\/api\/\*\)$|^Bash\(curl \* http:\/\/localhost:41[789]\*\/api\/\*\)$/;
1808
1809
  pendingCreatedFlag = false;
1809
1810
  }
1810
1811
  });
@@ -1946,6 +1947,7 @@ async function buildWorkflowInstructions(port, secretHeader) {
1946
1947
  sections.push('- The "notes" field is REQUIRED when completing a ticket. Describe the specific work done.');
1947
1948
  sections.push("- If an API call fails (e.g. connection refused, 403 secret mismatch, or error response), **re-read `.hotsheet/settings.json`** to get the correct `port` and `secret` values \u2014 you may be connecting to the wrong Hot Sheet instance. Log a visible warning to the user and continue your work. Do NOT silently skip status updates.");
1948
1949
  sections.push('- Do NOT set tickets to "verified" \u2014 that status is reserved for human review.');
1950
+ sections.push("- Do NOT use the API to read or list tickets (e.g., GET /api/tickets). Always read this worklist file for current work items. The API is only for updating ticket status and creating new tickets.");
1949
1951
  sections.push("");
1950
1952
  sections.push("## Creating Tickets");
1951
1953
  sections.push("");
@@ -2271,13 +2273,12 @@ async function seedDemoExtraProjects(scenario, primaryDataDir, port) {
2271
2273
  const path = await import("path");
2272
2274
  const { registerProject: registerProject2 } = await Promise.resolve().then(() => (init_projects(), projects_exports));
2273
2275
  const { writeFileSettings: writeFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
2274
- const { setDataDir: setDir, getDb: getDatabase } = await Promise.resolve().then(() => (init_connection(), connection_exports));
2276
+ const { getDbForDir: getDbForDir2 } = await Promise.resolve().then(() => (init_connection(), connection_exports));
2275
2277
  const baseDir = path.dirname(primaryDataDir);
2276
2278
  for (const extra of EXTRA_PROJECTS) {
2277
- const extraDataDir = path.join(baseDir, `hotsheet-demo-${extra.appName.toLowerCase().replace(/\s+/g, "-")}`);
2279
+ const extraDataDir = path.join(baseDir, `${path.basename(primaryDataDir)}-${extra.appName.toLowerCase().replace(/\s+/g, "-")}`);
2278
2280
  fs.mkdirSync(extraDataDir, { recursive: true });
2279
- setDir(extraDataDir);
2280
- const db = await getDatabase();
2281
+ const db = await getDbForDir2(extraDataDir);
2281
2282
  for (let i = 0; i < extra.tickets.length; i++) {
2282
2283
  const t = extra.tickets[i];
2283
2284
  const ticketNumber = `HS-${i + 1}`;
@@ -2293,10 +2294,8 @@ async function seedDemoExtraProjects(scenario, primaryDataDir, port) {
2293
2294
  }
2294
2295
  await db.query(`SELECT setval('ticket_seq', $1)`, [extra.tickets.length]);
2295
2296
  writeFileSettings2(extraDataDir, { appName: extra.appName });
2296
- setDir(primaryDataDir);
2297
2297
  await registerProject2(extraDataDir, port);
2298
2298
  }
2299
- setDir(primaryDataDir);
2300
2299
  }
2301
2300
  var DEMO_SCENARIOS, noteId, SCENARIO_1, SCENARIO_2, SCENARIO_3, SCENARIO_4, SCENARIO_5, SCENARIO_6, SCENARIO_7, SCENARIO_8, SCENARIO_9, SCENARIO_DATA, SCENARIO_3_VIEWS, SCENARIO_9_COMMANDS, EXTRA_PROJECTS;
2302
2301
  var init_demo = __esm({
@@ -3217,9 +3216,14 @@ var init_demo = __esm({
3217
3216
  ];
3218
3217
  SCENARIO_9_COMMANDS = [
3219
3218
  { name: "Commit Changes", prompt: "Make a commit for the recently completed tickets.", icon: "git-commit-horizontal", color: "#6b7280" },
3220
- { name: "Run Tests", prompt: "Run the test suite and report any failures.", icon: "test-tubes", color: "#3b82f6" },
3221
- { name: "Code Review", prompt: "Review the recent changes for code quality and potential issues.", icon: "search-code", color: "#8b5cf6" },
3222
- { name: "Deploy Staging", prompt: "Deploy the current branch to the staging environment.", icon: "rocket", color: "#f97316" }
3219
+ { type: "group", name: "Testing", children: [
3220
+ { name: "Run Tests", prompt: "Run the test suite and report any failures.", icon: "test-tubes", color: "#3b82f6" },
3221
+ { name: "Code Review", prompt: "Review the recent changes for code quality and potential issues.", icon: "search-code", color: "#8b5cf6" }
3222
+ ] },
3223
+ { type: "group", name: "Deploy", children: [
3224
+ { name: "Deploy Staging", prompt: "Deploy the current branch to the staging environment.", icon: "rocket", color: "#f97316" },
3225
+ { name: "Deploy Production", prompt: "Deploy to production after staging verification.", icon: "rocket", color: "#ef4444", target: "shell" }
3226
+ ] }
3223
3227
  ];
3224
3228
  EXTRA_PROJECTS = [
3225
3229
  {
@@ -3400,7 +3404,6 @@ function notifyPermission() {
3400
3404
  permissionVersion++;
3401
3405
  const waiters = permissionWaiters;
3402
3406
  permissionWaiters = [];
3403
- console.log(`[perm] notifyPermission v${permissionVersion}, waking ${waiters.length} waiters`);
3404
3407
  for (const resolve9 of waiters) resolve9();
3405
3408
  }
3406
3409
  var changeVersion, pollWaiters, permissionVersion, permissionWaiters;
@@ -3418,15 +3421,18 @@ var init_notify = __esm({
3418
3421
  // src/channel-config.ts
3419
3422
  var channel_config_exports = {};
3420
3423
  __export(channel_config_exports, {
3424
+ checkChannelVersion: () => checkChannelVersion,
3425
+ cleanupStaleChannel: () => cleanupStaleChannel,
3421
3426
  getChannelPort: () => getChannelPort,
3422
3427
  isChannelAlive: () => isChannelAlive,
3423
3428
  registerChannel: () => registerChannel,
3424
3429
  registerChannelForAll: () => registerChannelForAll,
3430
+ shutdownChannel: () => shutdownChannel,
3425
3431
  triggerChannel: () => triggerChannel,
3426
3432
  unregisterChannel: () => unregisterChannel,
3427
3433
  unregisterChannelForAll: () => unregisterChannelForAll
3428
3434
  });
3429
- import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
3435
+ import { existsSync as existsSync9, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync8 } from "fs";
3430
3436
  import { dirname as dirname2, join as join11, resolve as resolve5 } from "path";
3431
3437
  import { fileURLToPath } from "url";
3432
3438
  function getChannelServerPath() {
@@ -3496,6 +3502,41 @@ function getChannelPort(dataDir) {
3496
3502
  return null;
3497
3503
  }
3498
3504
  }
3505
+ async function cleanupStaleChannel(dataDir) {
3506
+ const port = getChannelPort(dataDir);
3507
+ if (port === null) return;
3508
+ const alive = await isChannelAlive(dataDir);
3509
+ if (!alive) {
3510
+ try {
3511
+ unlinkSync(join11(dataDir, "channel-port"));
3512
+ } catch {
3513
+ }
3514
+ }
3515
+ }
3516
+ async function checkChannelVersion(dataDir) {
3517
+ const port = getChannelPort(dataDir);
3518
+ if (port === null) return null;
3519
+ try {
3520
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
3521
+ const data = await res.json();
3522
+ const running = data.version ?? 0;
3523
+ return { match: running === EXPECTED_CHANNEL_VERSION, running, expected: EXPECTED_CHANNEL_VERSION };
3524
+ } catch {
3525
+ return null;
3526
+ }
3527
+ }
3528
+ async function shutdownChannel(dataDir) {
3529
+ const port = getChannelPort(dataDir);
3530
+ if (port === null) return;
3531
+ try {
3532
+ await fetch(`http://127.0.0.1:${port}/shutdown`, { method: "POST" });
3533
+ } catch {
3534
+ }
3535
+ try {
3536
+ unlinkSync(join11(dataDir, "channel-port"));
3537
+ } catch {
3538
+ }
3539
+ }
3499
3540
  async function isChannelAlive(dataDir) {
3500
3541
  const port = getChannelPort(dataDir);
3501
3542
  if (port === null) return false;
@@ -3532,11 +3573,12 @@ curl -s -X POST http://localhost:${serverPort}/api/channel/done${secretHeader}`;
3532
3573
  return false;
3533
3574
  }
3534
3575
  }
3535
- var MCP_SERVER_KEY;
3576
+ var MCP_SERVER_KEY, EXPECTED_CHANNEL_VERSION;
3536
3577
  var init_channel_config = __esm({
3537
3578
  "src/channel-config.ts"() {
3538
3579
  "use strict";
3539
3580
  MCP_SERVER_KEY = "hotsheet-channel";
3581
+ EXPECTED_CHANNEL_VERSION = 3;
3540
3582
  }
3541
3583
  });
3542
3584
 
@@ -3592,23 +3634,32 @@ async function cleanupAttachments() {
3592
3634
  const trashDays = parseInt(settings.trash_cleanup_days, 10) || 3;
3593
3635
  const tickets = await getTicketsForCleanup(verifiedDays, trashDays);
3594
3636
  if (tickets.length === 0) return;
3595
- let cleaned = 0;
3637
+ let archived = 0;
3638
+ let deleted = 0;
3596
3639
  for (const ticket of tickets) {
3597
- const attachments = await getAttachments(ticket.id);
3598
- for (const att of attachments) {
3599
- try {
3600
- rmSync3(att.stored_path, { force: true });
3601
- } catch {
3640
+ if (ticket.status === "verified") {
3641
+ await updateTicket(ticket.id, { status: "archive" });
3642
+ archived++;
3643
+ } else {
3644
+ const attachments = await getAttachments(ticket.id);
3645
+ for (const att of attachments) {
3646
+ try {
3647
+ rmSync3(att.stored_path, { force: true });
3648
+ } catch {
3649
+ }
3602
3650
  }
3651
+ await hardDeleteTicket(ticket.id);
3652
+ deleted++;
3603
3653
  }
3604
- await hardDeleteTicket(ticket.id);
3605
- cleaned++;
3606
3654
  }
3607
- if (cleaned > 0) {
3608
- console.log(` Cleaned up ${cleaned} old ticket(s) and their attachments.`);
3655
+ if (archived > 0 || deleted > 0) {
3656
+ const parts = [];
3657
+ if (archived > 0) parts.push(`archived ${archived} verified ticket(s)`);
3658
+ if (deleted > 0) parts.push(`deleted ${deleted} trashed ticket(s)`);
3659
+ console.log(` Cleanup: ${parts.join(", ")}.`);
3609
3660
  }
3610
3661
  } catch (err) {
3611
- console.error("Attachment cleanup failed:", err);
3662
+ console.error("Cleanup failed:", err);
3612
3663
  }
3613
3664
  }
3614
3665
 
@@ -17572,7 +17623,7 @@ config(en_default());
17572
17623
  // src/routes/validation.ts
17573
17624
  var TicketPrioritySchema = external_exports.enum(["highest", "high", "default", "low", "lowest"]);
17574
17625
  var TicketStatusSchema = external_exports.enum(["not_started", "started", "completed", "verified", "backlog", "archive", "deleted"]);
17575
- var SortBySchema = external_exports.enum(["created", "priority", "category", "status", "ticket_number"]);
17626
+ var SortBySchema = external_exports.enum(["created", "priority", "category", "status"]);
17576
17627
  var SortDirSchema = external_exports.enum(["asc", "desc"]);
17577
17628
  var CreateTicketSchema = external_exports.object({
17578
17629
  title: external_exports.string().optional().default(""),
@@ -17662,6 +17713,12 @@ var UpdateCategoriesSchema = external_exports.array(CategoryDefSchema).min(1);
17662
17713
  var PrintSchema = external_exports.object({
17663
17714
  html: external_exports.string()
17664
17715
  });
17716
+ var GlobalConfigSchema = external_exports.object({
17717
+ channelEnabled: external_exports.boolean().optional(),
17718
+ shareTotalSeconds: external_exports.number().optional(),
17719
+ shareLastPrompted: external_exports.string().optional(),
17720
+ shareAccepted: external_exports.boolean().optional()
17721
+ }).strict();
17665
17722
  function parseBody(schema, data) {
17666
17723
  const result = schema.safeParse(data);
17667
17724
  if (!result.success) {
@@ -17689,7 +17746,7 @@ channelRoutes.get("/channel/claude-check", async (c) => {
17689
17746
  }
17690
17747
  });
17691
17748
  channelRoutes.get("/channel/status", async (c) => {
17692
- const { isChannelAlive: isChannelAlive2, getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
17749
+ const { isChannelAlive: isChannelAlive2, getChannelPort: getChannelPort2, checkChannelVersion: checkChannelVersion2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
17693
17750
  const { readGlobalConfig: readGlobalConfig2 } = await Promise.resolve().then(() => (init_global_config(), global_config_exports));
17694
17751
  const dataDir = c.get("dataDir");
17695
17752
  const globalConfig2 = readGlobalConfig2();
@@ -17705,7 +17762,12 @@ channelRoutes.get("/channel/status", async (c) => {
17705
17762
  const projectSecret = c.get("projectSecret");
17706
17763
  const done = channelDoneFlags.get(projectSecret) === true;
17707
17764
  if (done) channelDoneFlags.delete(projectSecret);
17708
- return c.json({ enabled, alive, port, done });
17765
+ let versionMismatch = false;
17766
+ if (alive) {
17767
+ const vCheck = await checkChannelVersion2(dataDir);
17768
+ if (vCheck !== null && !vCheck.match) versionMismatch = true;
17769
+ }
17770
+ return c.json({ enabled, alive, port, done, versionMismatch });
17709
17771
  });
17710
17772
  channelRoutes.post("/channel/trigger", async (c) => {
17711
17773
  const { triggerChannel: triggerChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
@@ -17830,26 +17892,29 @@ channelRoutes.post("/channel/enable", async (c) => {
17830
17892
  return c.json({ ok: true });
17831
17893
  });
17832
17894
  channelRoutes.post("/channel/disable", async (c) => {
17833
- const { unregisterChannel: unregisterChannel2, unregisterChannelForAll: unregisterChannelForAll2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
17895
+ const { unregisterChannel: unregisterChannel2, unregisterChannelForAll: unregisterChannelForAll2, shutdownChannel: shutdownChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
17834
17896
  const { writeGlobalConfig: writeGlobalConfig2 } = await Promise.resolve().then(() => (init_global_config(), global_config_exports));
17835
17897
  const dataDir = c.get("dataDir");
17836
17898
  writeGlobalConfig2({ channelEnabled: false });
17837
17899
  try {
17838
17900
  const { getAllProjects: getAllProjects2 } = await Promise.resolve().then(() => (init_projects(), projects_exports));
17839
- unregisterChannelForAll2(getAllProjects2().map((p) => p.dataDir));
17901
+ const projects2 = getAllProjects2();
17902
+ unregisterChannelForAll2(projects2.map((p) => p.dataDir));
17903
+ for (const p of projects2) {
17904
+ await shutdownChannel2(p.dataDir);
17905
+ }
17840
17906
  } catch {
17841
17907
  unregisterChannel2(dataDir);
17908
+ await shutdownChannel2(dataDir);
17842
17909
  }
17843
17910
  notifyChange();
17844
17911
  return c.json({ ok: true });
17845
17912
  });
17846
17913
  channelRoutes.post("/channel/notify", (c) => {
17847
- console.log(`[notify] channel/notify received \u2014 waking poll + permission waiters`);
17848
17914
  notifyChange();
17849
17915
  return c.json({ ok: true });
17850
17916
  });
17851
17917
  channelRoutes.post("/channel/permission/notify", (c) => {
17852
- console.log(`[perm] notify received, waking waiters at ${Date.now()}`);
17853
17918
  notifyPermission();
17854
17919
  return c.json({ ok: true });
17855
17920
  });
@@ -17957,12 +18022,11 @@ dashboardRoutes.get("/global-config", async (c) => {
17957
18022
  return c.json(readGlobalConfig2());
17958
18023
  });
17959
18024
  dashboardRoutes.patch("/global-config", async (c) => {
17960
- const body = await c.req.json();
17961
- if (typeof body !== "object" || body === null || Array.isArray(body)) {
17962
- return c.json({ error: "Invalid body: expected an object" }, 400);
17963
- }
18025
+ const raw = await c.req.json();
18026
+ const parsed = parseBody(GlobalConfigSchema, raw);
18027
+ if (!parsed.success) return c.json({ error: parsed.error }, 400);
17964
18028
  const { writeGlobalConfig: writeGlobalConfig2 } = await Promise.resolve().then(() => (init_global_config(), global_config_exports));
17965
- const merged = writeGlobalConfig2(body);
18029
+ const merged = writeGlobalConfig2(parsed.data);
17966
18030
  return c.json(merged);
17967
18031
  });
17968
18032
  dashboardRoutes.post("/ensure-skills", (c) => {
@@ -18054,6 +18118,7 @@ settingsRoutes.patch("/settings", async (c) => {
18054
18118
  for (const [key, value] of Object.entries(parsed.data)) {
18055
18119
  await updateSetting(key, value);
18056
18120
  }
18121
+ notifyChange();
18057
18122
  return c.json({ ok: true });
18058
18123
  });
18059
18124
  settingsRoutes.get("/file-settings", async (c) => {
@@ -18754,6 +18819,11 @@ pageRoutes.get("/", (c) => {
18754
18819
  /* @__PURE__ */ jsx("div", { className: "app-body", children: [
18755
18820
  /* @__PURE__ */ jsx("nav", { className: "sidebar", children: [
18756
18821
  /* @__PURE__ */ jsx("div", { className: "channel-disconnected-warning", id: "channel-disconnected", style: "display:none", children: "Claude not connected" }),
18822
+ /* @__PURE__ */ jsx("div", { className: "channel-version-warning", id: "channel-version-warning", style: "display:none", children: [
18823
+ "Channel outdated \u2014 run ",
18824
+ /* @__PURE__ */ jsx("code", { children: "/mcp" }),
18825
+ " in Claude Code to reconnect"
18826
+ ] }),
18757
18827
  /* @__PURE__ */ jsx("div", { className: "sidebar-channel-play", id: "channel-play-section", style: "display:none", children: /* @__PURE__ */ jsx("button", { className: "channel-play-btn", id: "channel-play-btn", title: "Run worklist (double-click for auto mode)", children: [
18758
18828
  /* @__PURE__ */ jsx("span", { className: "channel-play-icon", id: "channel-play-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", stroke: "none", children: /* @__PURE__ */ jsx("polygon", { points: "6 3 20 12 6 21 6 3" }) }) }),
18759
18829
  /* @__PURE__ */ jsx("span", { className: "channel-auto-icon", id: "channel-auto-icon", style: "display:none", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "white", stroke: "none", children: [
@@ -19110,11 +19180,11 @@ pageRoutes.get("/", (c) => {
19110
19180
  /* @__PURE__ */ jsx("span", { className: "settings-hint", id: "settings-ticket-prefix-hint", children: "Prefix for ticket numbers (e.g. HS-1). Alphanumeric, hyphens, underscores. Max 10 characters." })
19111
19181
  ] }),
19112
19182
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
19113
- /* @__PURE__ */ jsx("label", { children: "Auto-clear trash after (days)" }),
19183
+ /* @__PURE__ */ jsx("label", { children: "Auto-delete trash after (days)" }),
19114
19184
  /* @__PURE__ */ jsx("input", { type: "number", id: "settings-trash-days", min: "1", value: "3" })
19115
19185
  ] }),
19116
19186
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
19117
- /* @__PURE__ */ jsx("label", { children: "Auto-clear verified after (days)" }),
19187
+ /* @__PURE__ */ jsx("label", { children: "Auto-archive verified after (days)" }),
19118
19188
  /* @__PURE__ */ jsx("input", { type: "number", id: "settings-verified-days", min: "1", value: "30" })
19119
19189
  ] }),
19120
19190
  /* @__PURE__ */ jsx("div", { className: "settings-field settings-field-checkbox", children: [
@@ -19124,6 +19194,13 @@ pageRoutes.get("/", (c) => {
19124
19194
  ] }),
19125
19195
  /* @__PURE__ */ jsx("span", { className: "settings-hint", children: "When no Up Next items exist, the AI will evaluate open tickets and choose what to work on next." })
19126
19196
  ] }),
19197
+ /* @__PURE__ */ jsx("div", { className: "settings-field settings-field-checkbox", children: [
19198
+ /* @__PURE__ */ jsx("label", { children: [
19199
+ /* @__PURE__ */ jsx("input", { type: "checkbox", id: "settings-hide-verified-column" }),
19200
+ " Hide Verified column in column view"
19201
+ ] }),
19202
+ /* @__PURE__ */ jsx("span", { className: "settings-hint", children: "Hides the Verified column in column view. Verified tickets will appear in the Completed column instead." })
19203
+ ] }),
19127
19204
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
19128
19205
  /* @__PURE__ */ jsx("label", { children: "When Claude needs permission" }),
19129
19206
  /* @__PURE__ */ jsx("select", { id: "settings-notify-permission", children: [
@@ -19866,9 +19943,12 @@ async function postStartup(dataDir, actualPort, demo, noOpen) {
19866
19943
  addToProjectList(dataDir);
19867
19944
  const previousProjects = readProjectList();
19868
19945
  const absDataDir = resolve8(dataDir);
19869
- const validProjects = [absDataDir];
19946
+ const validProjects = [];
19870
19947
  for (const prevDir of previousProjects) {
19871
- if (prevDir === absDataDir) continue;
19948
+ if (prevDir === absDataDir) {
19949
+ validProjects.push(prevDir);
19950
+ continue;
19951
+ }
19872
19952
  if (!existsSync15(prevDir)) continue;
19873
19953
  try {
19874
19954
  await registerProject(prevDir, actualPort);
@@ -19880,6 +19960,11 @@ async function postStartup(dataDir, actualPort, demo, noOpen) {
19880
19960
  const { reorderProjectList: reorderProjectList2 } = await Promise.resolve().then(() => (init_project_list(), project_list_exports));
19881
19961
  reorderProjectList2(validProjects);
19882
19962
  }
19963
+ if (validProjects.length > 1) {
19964
+ const { getProjectByDataDir: getByDir, reorderProjects: reorder } = await Promise.resolve().then(() => (init_projects(), projects_exports));
19965
+ const secrets = validProjects.map((dir) => getByDir(dir)?.secret).filter((s) => s !== void 0);
19966
+ if (secrets.length > 1) reorder(secrets);
19967
+ }
19883
19968
  if (validProjects.length > 1) {
19884
19969
  const { notifyChange: notifyChange2 } = await Promise.resolve().then(() => (init_notify(), notify_exports));
19885
19970
  notifyChange2();
@@ -19892,6 +19977,13 @@ async function postStartup(dataDir, actualPort, demo, noOpen) {
19892
19977
  const legacy = settings.channel_enabled === "true";
19893
19978
  writeGlobalConfig2({ channelEnabled: legacy });
19894
19979
  }
19980
+ {
19981
+ const { cleanupStaleChannel: cleanupStaleChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
19982
+ const { getAllProjects: allProjects } = await Promise.resolve().then(() => (init_projects(), projects_exports));
19983
+ for (const p of allProjects()) {
19984
+ await cleanupStaleChannel2(p.dataDir);
19985
+ }
19986
+ }
19895
19987
  {
19896
19988
  const { getAllProjects: getAllProjects2 } = await Promise.resolve().then(() => (init_projects(), projects_exports));
19897
19989
  const { ensureSkillsForDir: ensureSkillsForDir2 } = await Promise.resolve().then(() => (init_skills(), skills_exports));