hotsheet 0.10.4 → 0.11.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
@@ -107,20 +107,21 @@ The loop stays tight because the AI always knows what to work on next.
107
107
  **Also includes:**
108
108
  - **Tags** — free-form tags on tickets, with autocomplete and a batch tag dialog for multi-select
109
109
  - **Custom views** — create filtered views with an interactive query builder (field + operator + value conditions, AND/OR logic)
110
- - **Five priority levels** — Highest to Lowest, sortable and filterable
110
+ - **Five priority levels** — Highest to Lowest, with Lucide chevron icons, sortable and filterable
111
111
  - **Up Next flag** — star tickets to add them to the AI worklist
112
- - **Drag and drop** — drag tickets onto sidebar views to change category, priority, or status; reorder custom views
113
- - **Right-click context menus** — full context menu on tickets with category/priority/status submenus, tags, duplicate, delete
112
+ - **Drag and drop** — drag tickets onto sidebar views to change category, priority, or status; drop files onto the detail panel to attach; reorder custom views
113
+ - **Right-click context menus** — full context menu on tickets with category/priority/status submenus, tags, duplicate, backlog, archive, delete — all with Lucide icons
114
114
  - **Search** — full-text search across ticket titles, details, and ticket numbers
115
115
  - **Print** — print the dashboard, all tickets, selected tickets, or individual tickets in checklist, summary, or full-detail format
116
116
  - **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
117
117
  - **Undo/redo** — `Cmd+Z` and `Cmd+Shift+Z` for all operations including notes, batch changes, and deletions
118
118
  - **Animated transitions** — smooth FLIP animations when tickets reorder after property changes
119
119
  - **Copy for commits** — `Cmd+C` copies selected ticket info (number + title + details + notes) for use in commit messages
120
- - **File attachments** — attach files to any ticket, reveal in file manager
120
+ - **File attachments** — attach files via file picker or drag-and-drop onto the detail panel, reveal in file manager
121
121
  - **Markdown sync** — `worklist.md` and `open-tickets.md` auto-generated on every change
122
122
  - **Automatic backups** — tiered snapshots (every 5 min, hourly, daily) with preview-before-restore recovery
123
123
  - **Auto-cleanup** — configurable auto-deletion of old trash and verified items
124
+ - **App icon variants** — 9 icon variants to choose from in Settings, applied instantly to the dock icon
124
125
  - **Fully local** — embedded PostgreSQL (PGLite), no network calls, no accounts, no telemetry
125
126
 
126
127
  ---
@@ -156,9 +157,12 @@ Hot Sheet automatically generates skill files for Claude Code (as well as Cursor
156
157
  Hot Sheet can push events directly to a running Claude Code session via MCP channels. Enable it in Settings → Experimental (the tab only appears when Claude Code is detected on your system):
157
158
 
158
159
  - **Play button** — appears in the sidebar. Single-click sends the worklist to Claude on demand.
159
- - **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.
160
- - **Custom commands** — create named buttons that send custom prompts to Claude. For example, a "Commit Changes" button that tells Claude to generate a commit message from recently completed tickets and commit. Configure in Settings Experimental Custom Commands.
161
- - **Status indicator** — shows "Claude working" / "Claude idle" in the footer.
160
+ - **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.
161
+ - **Auto-prioritize** — when no tickets are flagged as Up Next, Claude automatically evaluates open tickets and picks the most important ones to work on.
162
+ - **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.
163
+ - **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.
164
+ - **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.
165
+ - **Status indicator** — shows "Claude working" / "Shell running" / idle in the footer.
162
166
 
163
167
  Requires Claude Code v2.1.80+ with channel support. See [docs/12-claude-channel.md](docs/12-claude-channel.md) for setup details.
164
168
 
@@ -293,7 +297,10 @@ hotsheet --browser
293
297
  |------|-------------|
294
298
  | `--port <number>` | Port to run on (default: 4174) |
295
299
  | `--data-dir <path>` | Data directory (default: `.hotsheet/`) |
300
+ | `--no-open` | Don't open the browser on startup |
301
+ | `--strict-port` | Fail if the requested port is in use |
296
302
  | `--browser` | Open in browser instead of desktop window |
303
+ | `--check-for-updates` | Check for new versions |
297
304
  | `--help` | Show help |
298
305
 
299
306
  ### Settings file
@@ -311,8 +318,9 @@ Create `.hotsheet/settings.json` to configure per-project options:
311
318
  |-----|-------------|
312
319
  | `appName` | Custom window title (defaults to the project folder name) |
313
320
  | `backupDir` | Backup storage path (defaults to `.hotsheet/backups/`) |
321
+ | `appIcon` | Icon variant (`default`, `variant-1` through `variant-9`) |
314
322
 
315
- Both settings can also be changed from the settings panel UI.
323
+ All settings can also be changed from the settings panel UI.
316
324
 
317
325
  ### Keyboard shortcuts
318
326
 
@@ -360,10 +368,16 @@ npm install
360
368
 
361
369
  npm run dev # Build client assets, then run via tsx
362
370
  npm run build # Build to dist/cli.js
371
+ npm test # Unit tests with coverage (446 tests)
372
+ npm run test:e2e # E2E browser tests (55 tests)
373
+ npm run test:all # Merged coverage report (unit + E2E)
374
+ npm run lint # ESLint
363
375
  npm run clean # Remove dist and caches
364
376
  npm link # Symlink for global 'hotsheet' command
365
377
  ```
366
378
 
379
+ 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.
380
+
367
381
  ---
368
382
 
369
383
  ## See Also
package/dist/cli.js CHANGED
@@ -74,10 +74,10 @@ async function initSchema(db2) {
74
74
  priority TEXT NOT NULL DEFAULT 'default',
75
75
  status TEXT NOT NULL DEFAULT 'not_started',
76
76
  up_next BOOLEAN NOT NULL DEFAULT FALSE,
77
- created_at TIMESTAMP NOT NULL DEFAULT NOW(),
78
- updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
79
- completed_at TIMESTAMP,
80
- deleted_at TIMESTAMP
77
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79
+ completed_at TIMESTAMPTZ,
80
+ deleted_at TIMESTAMPTZ
81
81
  );
82
82
 
83
83
  CREATE TABLE IF NOT EXISTS attachments (
@@ -85,7 +85,7 @@ async function initSchema(db2) {
85
85
  ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
86
86
  original_filename TEXT NOT NULL,
87
87
  stored_path TEXT NOT NULL,
88
- created_at TIMESTAMP NOT NULL DEFAULT NOW()
88
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
89
89
  );
90
90
 
91
91
  CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON attachments(ticket_id);
@@ -110,9 +110,29 @@ async function initSchema(db2) {
110
110
  data TEXT NOT NULL DEFAULT '{}'
111
111
  );
112
112
  `);
113
+ await db2.exec(`
114
+ CREATE TABLE IF NOT EXISTS command_log (
115
+ id SERIAL PRIMARY KEY,
116
+ event_type TEXT NOT NULL,
117
+ direction TEXT NOT NULL DEFAULT 'system',
118
+ summary TEXT NOT NULL DEFAULT '',
119
+ detail TEXT NOT NULL DEFAULT '',
120
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_command_log_created ON command_log(created_at);
123
+ `);
124
+ await db2.exec(`
125
+ ALTER TABLE tickets ALTER COLUMN created_at TYPE TIMESTAMPTZ;
126
+ ALTER TABLE tickets ALTER COLUMN updated_at TYPE TIMESTAMPTZ;
127
+ ALTER TABLE tickets ALTER COLUMN completed_at TYPE TIMESTAMPTZ;
128
+ ALTER TABLE tickets ALTER COLUMN deleted_at TYPE TIMESTAMPTZ;
129
+ ALTER TABLE attachments ALTER COLUMN created_at TYPE TIMESTAMPTZ;
130
+ ALTER TABLE command_log ALTER COLUMN created_at TYPE TIMESTAMPTZ;
131
+ `).catch(() => {
132
+ });
113
133
  await db2.exec(`
114
134
  ALTER TABLE tickets ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
115
- ALTER TABLE tickets ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP;
135
+ ALTER TABLE tickets ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
116
136
  ALTER TABLE tickets ADD COLUMN IF NOT EXISTS tags TEXT NOT NULL DEFAULT '[]';
117
137
  `).catch(() => {
118
138
  });
@@ -184,6 +204,108 @@ var init_file_settings = __esm({
184
204
  }
185
205
  });
186
206
 
207
+ // src/db/commandLog.ts
208
+ var commandLog_exports = {};
209
+ __export(commandLog_exports, {
210
+ addLogEntry: () => addLogEntry,
211
+ clearLog: () => clearLog,
212
+ getLogCount: () => getLogCount,
213
+ getLogEntries: () => getLogEntries,
214
+ pruneLog: () => pruneLog,
215
+ updateLogEntry: () => updateLogEntry
216
+ });
217
+ async function addLogEntry(eventType, direction, summary, detail) {
218
+ const db2 = await getDb();
219
+ const result = await db2.query(
220
+ `INSERT INTO command_log (event_type, direction, summary, detail) VALUES ($1, $2, $3, $4) RETURNING *`,
221
+ [eventType, direction, summary, detail]
222
+ );
223
+ return result.rows[0];
224
+ }
225
+ async function getLogEntries(options) {
226
+ const db2 = await getDb();
227
+ const conditions = [];
228
+ const params = [];
229
+ let paramIdx = 1;
230
+ if (options?.eventType !== void 0 && options.eventType !== "") {
231
+ conditions.push(`event_type = $${paramIdx++}`);
232
+ params.push(options.eventType);
233
+ }
234
+ if (options?.search !== void 0 && options.search !== "") {
235
+ conditions.push(`(summary ILIKE $${paramIdx} OR detail ILIKE $${paramIdx})`);
236
+ params.push(`%${options.search}%`);
237
+ paramIdx++;
238
+ }
239
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
240
+ const limit = options?.limit ?? 100;
241
+ const offset = options?.offset ?? 0;
242
+ const result = await db2.query(
243
+ `SELECT id, event_type, direction, summary, detail, created_at
244
+ FROM command_log ${where}
245
+ ORDER BY created_at DESC, id DESC
246
+ LIMIT $${paramIdx++} OFFSET $${paramIdx}`,
247
+ [...params, limit, offset]
248
+ );
249
+ return result.rows;
250
+ }
251
+ async function getLogCount(options) {
252
+ const db2 = await getDb();
253
+ const conditions = [];
254
+ const params = [];
255
+ let paramIdx = 1;
256
+ if (options?.eventType !== void 0 && options.eventType !== "") {
257
+ conditions.push(`event_type = $${paramIdx++}`);
258
+ params.push(options.eventType);
259
+ }
260
+ if (options?.search !== void 0 && options.search !== "") {
261
+ conditions.push(`(summary ILIKE $${paramIdx} OR detail ILIKE $${paramIdx})`);
262
+ params.push(`%${options.search}%`);
263
+ paramIdx++;
264
+ }
265
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
266
+ const result = await db2.query(
267
+ `SELECT COUNT(*) as count FROM command_log ${where}`,
268
+ params
269
+ );
270
+ return parseInt(result.rows[0].count, 10);
271
+ }
272
+ async function updateLogEntry(id, updates) {
273
+ const db2 = await getDb();
274
+ const sets = [];
275
+ const params = [];
276
+ let paramIdx = 1;
277
+ if (updates.summary !== void 0) {
278
+ sets.push(`summary = $${paramIdx++}`);
279
+ params.push(updates.summary);
280
+ }
281
+ if (updates.detail !== void 0) {
282
+ sets.push(`detail = $${paramIdx++}`);
283
+ params.push(updates.detail);
284
+ }
285
+ if (sets.length === 0) return;
286
+ params.push(id);
287
+ await db2.query(`UPDATE command_log SET ${sets.join(", ")} WHERE id = $${paramIdx}`, params);
288
+ }
289
+ async function clearLog() {
290
+ const db2 = await getDb();
291
+ await db2.query(`DELETE FROM command_log`);
292
+ }
293
+ async function pruneLog(maxEntries = 1e3) {
294
+ const db2 = await getDb();
295
+ await db2.query(
296
+ `DELETE FROM command_log WHERE id NOT IN (
297
+ SELECT id FROM command_log ORDER BY created_at DESC, id DESC LIMIT $1
298
+ )`,
299
+ [maxEntries]
300
+ );
301
+ }
302
+ var init_commandLog = __esm({
303
+ "src/db/commandLog.ts"() {
304
+ "use strict";
305
+ init_connection();
306
+ }
307
+ });
308
+
187
309
  // src/db/stats.ts
188
310
  var stats_exports = {};
189
311
  __export(stats_exports, {
@@ -441,6 +563,36 @@ var init_gitignore = __esm({
441
563
  }
442
564
  });
443
565
 
566
+ // src/routes/notify.ts
567
+ var notify_exports = {};
568
+ __export(notify_exports, {
569
+ addPollWaiter: () => addPollWaiter,
570
+ getChangeVersion: () => getChangeVersion,
571
+ notifyChange: () => notifyChange
572
+ });
573
+ function notifyChange() {
574
+ changeVersion++;
575
+ const waiters = pollWaiters;
576
+ pollWaiters = [];
577
+ for (const resolve5 of waiters) {
578
+ resolve5(changeVersion);
579
+ }
580
+ }
581
+ function getChangeVersion() {
582
+ return changeVersion;
583
+ }
584
+ function addPollWaiter(resolve5) {
585
+ pollWaiters.push(resolve5);
586
+ }
587
+ var changeVersion, pollWaiters;
588
+ var init_notify = __esm({
589
+ "src/routes/notify.ts"() {
590
+ "use strict";
591
+ changeVersion = 0;
592
+ pollWaiters = [];
593
+ }
594
+ });
595
+
444
596
  // src/channel-config.ts
445
597
  var channel_config_exports = {};
446
598
  __export(channel_config_exports, {
@@ -788,6 +940,9 @@ async function deleteAttachment(id) {
788
940
  return result.rows[0] ?? null;
789
941
  }
790
942
 
943
+ // src/db/queries.ts
944
+ init_commandLog();
945
+
791
946
  // src/db/notes.ts
792
947
  init_connection();
793
948
  var noteCounter = 0;
@@ -2404,12 +2559,12 @@ init_file_settings();
2404
2559
  import { serve } from "@hono/node-server";
2405
2560
  import { execFile } from "child_process";
2406
2561
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
2407
- import { Hono as Hono9 } from "hono";
2562
+ import { Hono as Hono11 } from "hono";
2408
2563
  import { dirname as dirname3, join as join11 } from "path";
2409
2564
  import { fileURLToPath as fileURLToPath2 } from "url";
2410
2565
 
2411
2566
  // src/routes/api.ts
2412
- import { Hono as Hono6 } from "hono";
2567
+ import { Hono as Hono8 } from "hono";
2413
2568
 
2414
2569
  // src/routes/attachments.ts
2415
2570
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, rmSync as rmSync5 } from "fs";
@@ -2652,25 +2807,8 @@ async function syncOpenTickets() {
2652
2807
  }
2653
2808
  }
2654
2809
 
2655
- // src/routes/notify.ts
2656
- var changeVersion = 0;
2657
- var pollWaiters = [];
2658
- function notifyChange() {
2659
- changeVersion++;
2660
- const waiters = pollWaiters;
2661
- pollWaiters = [];
2662
- for (const resolve5 of waiters) {
2663
- resolve5(changeVersion);
2664
- }
2665
- }
2666
- function getChangeVersion() {
2667
- return changeVersion;
2668
- }
2669
- function addPollWaiter(resolve5) {
2670
- pollWaiters.push(resolve5);
2671
- }
2672
-
2673
2810
  // src/routes/attachments.ts
2811
+ init_notify();
2674
2812
  var attachmentRoutes = new Hono();
2675
2813
  attachmentRoutes.post("/tickets/:id/attachments", async (c) => {
2676
2814
  const id = parseInt(c.req.param("id"), 10);
@@ -2758,8 +2896,10 @@ attachmentRoutes.get("/attachments/file/*", async (c) => {
2758
2896
 
2759
2897
  // src/routes/channel.ts
2760
2898
  import { Hono as Hono2 } from "hono";
2899
+ init_notify();
2761
2900
  var channelRoutes = new Hono2();
2762
2901
  var channelDoneFlag = false;
2902
+ var loggedPermissionRequests = /* @__PURE__ */ new Map();
2763
2903
  channelRoutes.get("/channel/claude-check", async (c) => {
2764
2904
  const { execFileSync } = await import("child_process");
2765
2905
  try {
@@ -2790,7 +2930,11 @@ channelRoutes.post("/channel/trigger", async (c) => {
2790
2930
  const serverPort = parseInt(new URL(c.req.url).port || "4174", 10);
2791
2931
  const body = await c.req.json().catch(() => ({ message: void 0 }));
2792
2932
  channelDoneFlag = false;
2933
+ loggedPermissionRequests.clear();
2793
2934
  const ok = await triggerChannel2(dataDir2, serverPort, body.message);
2935
+ const summary = body.message !== void 0 && body.message !== "" ? body.message.slice(0, 200) : "Worklist trigger";
2936
+ addLogEntry("trigger", "outgoing", summary, body.message ?? "").catch(() => {
2937
+ });
2794
2938
  return c.json({ ok });
2795
2939
  });
2796
2940
  channelRoutes.get("/channel/permission", async (c) => {
@@ -2801,6 +2945,20 @@ channelRoutes.get("/channel/permission", async (c) => {
2801
2945
  try {
2802
2946
  const res = await fetch(`http://127.0.0.1:${port2}/permission`);
2803
2947
  const data = await res.json();
2948
+ if (data.pending !== null) {
2949
+ const reqId = data.pending.request_id ?? "";
2950
+ if (reqId !== "" && !loggedPermissionRequests.has(reqId)) {
2951
+ const toolName = data.pending.tool_name ?? "unknown tool";
2952
+ const description = data.pending.description ?? "";
2953
+ const inputPreview = data.pending.input_preview ?? (data.pending.tool_input !== void 0 ? JSON.stringify(data.pending.tool_input, null, 2).slice(0, 2e3) : "");
2954
+ const detail = (description !== "" ? description + "\n\n" : "") + inputPreview;
2955
+ addLogEntry("permission_request", "incoming", `Permission: ${toolName}`, detail).then((entry) => {
2956
+ loggedPermissionRequests.set(reqId, entry.id);
2957
+ }).catch(() => {
2958
+ loggedPermissionRequests.set(reqId, 0);
2959
+ });
2960
+ }
2961
+ }
2804
2962
  return c.json(data);
2805
2963
  } catch {
2806
2964
  return c.json({ pending: null });
@@ -2812,6 +2970,16 @@ channelRoutes.post("/channel/permission/respond", async (c) => {
2812
2970
  const port2 = getChannelPort2(dataDir2);
2813
2971
  if (port2 === null) return c.json({ error: "Channel not available" }, 503);
2814
2972
  const body = await c.req.json();
2973
+ const action = body.behavior === "allow" ? "Allowed" : "Denied";
2974
+ const toolName = body.tool_name ?? "tool";
2975
+ const logId = loggedPermissionRequests.get(body.request_id);
2976
+ if (logId !== void 0 && logId > 0) {
2977
+ updateLogEntry(logId, { summary: `Permission: ${toolName} \u2014 ${action}` }).catch(() => {
2978
+ });
2979
+ } else {
2980
+ addLogEntry("permission_request", "incoming", `Permission: ${toolName} \u2014 ${action}`, JSON.stringify(body)).catch(() => {
2981
+ });
2982
+ }
2815
2983
  try {
2816
2984
  const res = await fetch(`http://127.0.0.1:${port2}/permission/respond`, {
2817
2985
  method: "POST",
@@ -2836,6 +3004,8 @@ channelRoutes.post("/channel/permission/dismiss", async (c) => {
2836
3004
  });
2837
3005
  channelRoutes.post("/channel/done", (_c) => {
2838
3006
  channelDoneFlag = true;
3007
+ addLogEntry("done", "incoming", "Claude finished", "").catch(() => {
3008
+ });
2839
3009
  notifyChange();
2840
3010
  return _c.json({ ok: true });
2841
3011
  });
@@ -2855,9 +3025,33 @@ channelRoutes.post("/channel/disable", async (c) => {
2855
3025
  return c.json({ ok: true });
2856
3026
  });
2857
3027
 
3028
+ // src/routes/commandLog.ts
3029
+ import { Hono as Hono3 } from "hono";
3030
+ var commandLogRoutes = new Hono3();
3031
+ commandLogRoutes.get("/command-log", async (c) => {
3032
+ const url = new URL(c.req.url);
3033
+ const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
3034
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
3035
+ const eventType = url.searchParams.get("event_type") ?? void 0;
3036
+ const search = url.searchParams.get("search") ?? void 0;
3037
+ const entries = await getLogEntries({ limit, offset, eventType, search });
3038
+ return c.json(entries);
3039
+ });
3040
+ commandLogRoutes.delete("/command-log", async (c) => {
3041
+ await clearLog();
3042
+ return c.json({ ok: true });
3043
+ });
3044
+ commandLogRoutes.get("/command-log/count", async (c) => {
3045
+ const url = new URL(c.req.url);
3046
+ const eventType = url.searchParams.get("event_type") ?? void 0;
3047
+ const search = url.searchParams.get("search") ?? void 0;
3048
+ const count = await getLogCount({ eventType, search });
3049
+ return c.json({ count });
3050
+ });
3051
+
2858
3052
  // src/routes/dashboard.ts
2859
3053
  import { writeFileSync as writeFileSync7 } from "fs";
2860
- import { Hono as Hono3 } from "hono";
3054
+ import { Hono as Hono4 } from "hono";
2861
3055
  import { tmpdir } from "os";
2862
3056
  import { join as join10, relative as relative2 } from "path";
2863
3057
 
@@ -3126,7 +3320,8 @@ function consumeSkillsCreatedFlag() {
3126
3320
  }
3127
3321
 
3128
3322
  // src/routes/dashboard.ts
3129
- var dashboardRoutes = new Hono3();
3323
+ init_notify();
3324
+ var dashboardRoutes = new Hono4();
3130
3325
  dashboardRoutes.get("/poll", async (c) => {
3131
3326
  const clientVersion = parseInt(c.req.query("version") ?? "0", 10);
3132
3327
  const changeVersion2 = getChangeVersion();
@@ -3216,8 +3411,9 @@ dashboardRoutes.post("/print", async (c) => {
3216
3411
  });
3217
3412
 
3218
3413
  // src/routes/settings.ts
3219
- import { Hono as Hono4 } from "hono";
3220
- var settingsRoutes = new Hono4();
3414
+ import { Hono as Hono5 } from "hono";
3415
+ init_notify();
3416
+ var settingsRoutes = new Hono5();
3221
3417
  settingsRoutes.get("/tags", async (c) => {
3222
3418
  const tags = await getAllTags();
3223
3419
  return c.json(tags);
@@ -3265,10 +3461,93 @@ settingsRoutes.patch("/file-settings", async (c) => {
3265
3461
  return c.json(updated);
3266
3462
  });
3267
3463
 
3464
+ // src/routes/shell.ts
3465
+ init_commandLog();
3466
+ import { Hono as Hono6 } from "hono";
3467
+ var shellRoutes = new Hono6();
3468
+ var runningProcesses = /* @__PURE__ */ new Map();
3469
+ var killedProcesses = /* @__PURE__ */ new Set();
3470
+ shellRoutes.post("/shell/exec", async (c) => {
3471
+ const { spawn } = await import("child_process");
3472
+ const dataDir2 = c.get("dataDir");
3473
+ const body = await c.req.json();
3474
+ const command = body.command;
3475
+ const name = body.name;
3476
+ if (!command || command.trim() === "") {
3477
+ return c.json({ error: "No command provided" }, 400);
3478
+ }
3479
+ const cwd = dataDir2 + "/..";
3480
+ const summary = name !== void 0 && name !== "" ? name : command.slice(0, 200);
3481
+ const logEntry = await addLogEntry("shell_command", "outgoing", summary, command);
3482
+ const logId = logEntry.id;
3483
+ const child = spawn(command, { shell: true, cwd, stdio: ["ignore", "pipe", "pipe"] });
3484
+ runningProcesses.set(logId, child);
3485
+ let stdout = "";
3486
+ let stderr = "";
3487
+ child.stdout.on("data", (data) => {
3488
+ stdout += data.toString();
3489
+ });
3490
+ child.stderr.on("data", (data) => {
3491
+ stderr += data.toString();
3492
+ });
3493
+ child.on("close", (code, signal) => {
3494
+ runningProcesses.delete(logId);
3495
+ const wasCanceled = killedProcesses.has(logId);
3496
+ killedProcesses.delete(logId);
3497
+ const output = (stdout + (stderr ? "\n--- stderr ---\n" + stderr : "")).trim();
3498
+ const exitSummary = wasCanceled ? "Canceled" : code === 0 ? "Completed (exit 0)" : signal !== null ? `Killed by ${signal}` : `Exited with code ${code ?? "unknown"}`;
3499
+ const combinedDetail = command + "\n---SHELL_OUTPUT---\n" + output;
3500
+ if (logId > 0) {
3501
+ const label = name !== void 0 && name !== "" ? name : command.slice(0, 100);
3502
+ updateLogEntry(logId, { summary: `${label} \u2014 ${exitSummary}`, detail: combinedDetail }).catch(() => {
3503
+ });
3504
+ } else {
3505
+ addLogEntry("shell_command", "outgoing", exitSummary, combinedDetail).catch(() => {
3506
+ });
3507
+ }
3508
+ Promise.resolve().then(() => (init_notify(), notify_exports)).then(({ notifyChange: notifyChange2 }) => notifyChange2()).catch(() => {
3509
+ });
3510
+ });
3511
+ child.on("error", (err) => {
3512
+ runningProcesses.delete(logId);
3513
+ const combinedDetail = command + "\n---SHELL_OUTPUT---\n" + (err.stack ?? err.message);
3514
+ if (logId > 0) {
3515
+ updateLogEntry(logId, { summary: `${command.slice(0, 100)} \u2014 Error: ${err.message}`, detail: combinedDetail }).catch(() => {
3516
+ });
3517
+ } else {
3518
+ addLogEntry("shell_command", "outgoing", `Error: ${err.message}`, combinedDetail).catch(() => {
3519
+ });
3520
+ }
3521
+ Promise.resolve().then(() => (init_notify(), notify_exports)).then(({ notifyChange: notifyChange2 }) => notifyChange2()).catch(() => {
3522
+ });
3523
+ });
3524
+ return c.json({ id: logId });
3525
+ });
3526
+ shellRoutes.post("/shell/kill", async (c) => {
3527
+ const body = await c.req.json();
3528
+ const child = runningProcesses.get(body.id);
3529
+ if (!child) {
3530
+ return c.json({ error: "Process not found or already finished" }, 404);
3531
+ }
3532
+ killedProcesses.add(body.id);
3533
+ child.kill("SIGTERM");
3534
+ setTimeout(() => {
3535
+ if (runningProcesses.has(body.id)) {
3536
+ child.kill("SIGKILL");
3537
+ }
3538
+ }, 3e3);
3539
+ return c.json({ ok: true });
3540
+ });
3541
+ shellRoutes.get("/shell/running", (c) => {
3542
+ const ids = Array.from(runningProcesses.keys());
3543
+ return c.json({ ids });
3544
+ });
3545
+
3268
3546
  // src/routes/tickets.ts
3269
3547
  import { rmSync as rmSync6 } from "fs";
3270
- import { Hono as Hono5 } from "hono";
3271
- var ticketRoutes = new Hono5();
3548
+ import { Hono as Hono7 } from "hono";
3549
+ init_notify();
3550
+ var ticketRoutes = new Hono7();
3272
3551
  ticketRoutes.get("/tickets", async (c) => {
3273
3552
  const filters = {};
3274
3553
  const category = c.req.query("category");
@@ -3455,16 +3734,18 @@ ticketRoutes.post("/tickets/query", async (c) => {
3455
3734
  });
3456
3735
 
3457
3736
  // src/routes/api.ts
3458
- var apiRoutes = new Hono6();
3737
+ var apiRoutes = new Hono8();
3459
3738
  apiRoutes.route("/", ticketRoutes);
3460
3739
  apiRoutes.route("/", attachmentRoutes);
3461
3740
  apiRoutes.route("/", channelRoutes);
3741
+ apiRoutes.route("/", commandLogRoutes);
3462
3742
  apiRoutes.route("/", settingsRoutes);
3463
3743
  apiRoutes.route("/", dashboardRoutes);
3744
+ apiRoutes.route("/", shellRoutes);
3464
3745
 
3465
3746
  // src/routes/backups.ts
3466
- import { Hono as Hono7 } from "hono";
3467
- var backupRoutes = new Hono7();
3747
+ import { Hono as Hono9 } from "hono";
3748
+ var backupRoutes = new Hono9();
3468
3749
  backupRoutes.get("/", (c) => {
3469
3750
  const dataDir2 = c.get("dataDir");
3470
3751
  const backups = listBackups(dataDir2);
@@ -3513,7 +3794,7 @@ backupRoutes.post("/restore", async (c) => {
3513
3794
  });
3514
3795
 
3515
3796
  // src/routes/pages.tsx
3516
- import { Hono as Hono8 } from "hono";
3797
+ import { Hono as Hono10 } from "hono";
3517
3798
 
3518
3799
  // src/utils/escapeHtml.ts
3519
3800
  function escapeHtml(str) {
@@ -3708,7 +3989,7 @@ function Layout({ title, children }) {
3708
3989
  }
3709
3990
 
3710
3991
  // src/routes/pages.tsx
3711
- var pageRoutes = new Hono8();
3992
+ var pageRoutes = new Hono10();
3712
3993
  pageRoutes.get("/", (c) => {
3713
3994
  const html = /* @__PURE__ */ jsx(Layout, { title: "Hot Sheet", children: [
3714
3995
  /* @__PURE__ */ jsx("div", { className: "app", children: [
@@ -4050,8 +4331,44 @@ pageRoutes.get("/", (c) => {
4050
4331
  ] }),
4051
4332
  /* @__PURE__ */ jsx("div", { className: "status-bar-right", children: [
4052
4333
  /* @__PURE__ */ jsx("div", { id: "status-bar", className: "status-bar" }),
4334
+ /* @__PURE__ */ jsx("button", { id: "command-log-btn", className: "command-log-btn", title: "Commands Log", children: [
4335
+ /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4336
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
4337
+ /* @__PURE__ */ jsx("path", { d: "M3 15h18" }),
4338
+ /* @__PURE__ */ jsx("path", { d: "m9 10 3-3 3 3" })
4339
+ ] }),
4340
+ /* @__PURE__ */ jsx("span", { id: "command-log-badge", className: "command-log-badge", style: "display:none" })
4341
+ ] }),
4053
4342
  /* @__PURE__ */ jsx("span", { id: "channel-status-indicator", className: "channel-status-indicator", style: "display:none" })
4054
4343
  ] })
4344
+ ] }),
4345
+ /* @__PURE__ */ jsx("div", { id: "command-log-panel", className: "command-log-panel", style: "display:none", children: [
4346
+ /* @__PURE__ */ jsx("div", { className: "command-log-resize-handle", id: "command-log-resize" }),
4347
+ /* @__PURE__ */ jsx("div", { className: "command-log-header", children: [
4348
+ /* @__PURE__ */ jsx("span", { className: "command-log-title", children: "Commands Log" }),
4349
+ /* @__PURE__ */ jsx("div", { className: "command-log-search-box", children: [
4350
+ /* @__PURE__ */ jsx("svg", { className: "command-log-search-icon", xmlns: "http://www.w3.org/2000/svg", width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4351
+ /* @__PURE__ */ jsx("circle", { cx: "11", cy: "11", r: "8" }),
4352
+ /* @__PURE__ */ jsx("path", { d: "m21 21-4.3-4.3" })
4353
+ ] }),
4354
+ /* @__PURE__ */ jsx("input", { type: "text", id: "command-log-search", placeholder: "Search...", className: "command-log-search" })
4355
+ ] }),
4356
+ /* @__PURE__ */ jsx("button", { id: "command-log-filter-btn", className: "command-log-filter-btn", title: "Filter by type", children: [
4357
+ /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("polygon", { points: "22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" }) }),
4358
+ /* @__PURE__ */ jsx("span", { children: "All types" })
4359
+ ] }),
4360
+ /* @__PURE__ */ jsx("button", { id: "command-log-clear", className: "command-log-clear-btn", title: "Clear log", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4361
+ /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
4362
+ /* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
4363
+ /* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })
4364
+ ] }) }),
4365
+ /* @__PURE__ */ jsx("button", { id: "command-log-close", className: "command-log-close-btn", title: "Close", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4366
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }),
4367
+ /* @__PURE__ */ jsx("path", { d: "M3 15h18" }),
4368
+ /* @__PURE__ */ jsx("path", { d: "m15 8-3 3-3-3" })
4369
+ ] }) })
4370
+ ] }),
4371
+ /* @__PURE__ */ jsx("div", { id: "command-log-entries", className: "command-log-entries" })
4055
4372
  ] })
4056
4373
  ] }),
4057
4374
  /* @__PURE__ */ jsx("div", { className: "settings-overlay", id: "settings-overlay", style: "display:none", children: /* @__PURE__ */ jsx("div", { className: "settings-dialog", children: [
@@ -4100,7 +4417,7 @@ pageRoutes.get("/", (c) => {
4100
4417
  ] }),
4101
4418
  /* @__PURE__ */ jsx("span", { children: "Context" })
4102
4419
  ] }),
4103
- /* @__PURE__ */ jsx("button", { className: "settings-tab", "data-tab": "experimental", id: "settings-tab-experimental", style: "display:none", children: [
4420
+ /* @__PURE__ */ jsx("button", { className: "settings-tab", "data-tab": "experimental", id: "settings-tab-experimental", children: [
4104
4421
  /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4105
4422
  /* @__PURE__ */ jsx("path", { d: "M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2" }),
4106
4423
  /* @__PURE__ */ jsx("path", { d: "M8.5 2h7" }),
@@ -4187,7 +4504,7 @@ pageRoutes.get("/", (c) => {
4187
4504
  /* @__PURE__ */ jsx("span", { className: "settings-hint", style: "margin-bottom:12px;display:block", children: "Automatically prepend instructions to ticket details in the worklist, based on category or tag. Category context appears first, then tag context in alphabetical order." }),
4188
4505
  /* @__PURE__ */ jsx("div", { id: "auto-context-list" })
4189
4506
  ] }),
4190
- /* @__PURE__ */ jsx("div", { className: "settings-tab-panel", "data-panel": "experimental", id: "settings-experimental-panel", style: "display:none", children: [
4507
+ /* @__PURE__ */ jsx("div", { className: "settings-tab-panel", "data-panel": "experimental", id: "settings-experimental-panel", children: [
4191
4508
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
4192
4509
  /* @__PURE__ */ jsx("label", { className: "settings-checkbox-label", children: [
4193
4510
  /* @__PURE__ */ jsx("input", { type: "checkbox", id: "settings-channel-enabled" }),
@@ -4246,7 +4563,7 @@ function tryServe(fetch2, port2) {
4246
4563
  });
4247
4564
  }
4248
4565
  async function startServer(port2, dataDir2, options) {
4249
- const app = new Hono9();
4566
+ const app = new Hono11();
4250
4567
  app.use("*", async (c, next) => {
4251
4568
  c.set("dataDir", dataDir2);
4252
4569
  await next();
@@ -4573,6 +4890,8 @@ async function main() {
4573
4890
  AI tool skills created/updated for: ${updatedPlatforms.join(", ")}`);
4574
4891
  console.log(" Restart your AI tool to pick up the new ticket creation skills.\n");
4575
4892
  }
4893
+ Promise.resolve().then(() => (init_commandLog(), commandLog_exports)).then(({ pruneLog: pruneLog2 }) => pruneLog2(1e3)).catch(() => {
4894
+ });
4576
4895
  Promise.resolve().then(() => (init_stats(), stats_exports)).then(async ({ recordDailySnapshot: recordDailySnapshot2, backfillSnapshots: backfillSnapshots2 }) => {
4577
4896
  await backfillSnapshots2();
4578
4897
  await recordDailySnapshot2();