@danypops/tickets 0.1.0 → 0.2.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
@@ -51,8 +51,37 @@ bun run src/cli/index.ts create -b github "Fix the thing" --label bug
51
51
  bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
52
52
  bun run src/cli/index.ts ledger search "login bug"
53
53
  bun run src/cli/index.ts ledger stats
54
+
55
+ # Track the single ticket you're currently working on, with its full URL —
56
+ # survives daemon restarts, resolves the ref via the ledger first (no live
57
+ # call if it's already cached) and falls back to the backend otherwise.
58
+ bun run src/cli/index.ts focus set jira:PROJ-42
59
+ bun run src/cli/index.ts focus get
60
+ bun run src/cli/index.ts focus pause "waiting on review"
61
+ bun run src/cli/index.ts focus unpause
62
+ bun run src/cli/index.ts focus clear
54
63
  ```
55
64
 
65
+ ### Running the daemon persistently (systemd --user)
66
+
67
+ `daemon start` spawns the daemon on demand and it lives only as long as
68
+ something keeps it alive. For a daemon that survives logout/reboot, install
69
+ it as a systemd `--user` service instead (Linux only):
70
+
71
+ ```bash
72
+ bun run src/cli/index.ts service install # writes + enables + (re)starts the unit
73
+ bun run src/cli/index.ts service status
74
+ bun run src/cli/index.ts service stop
75
+ bun run src/cli/index.ts service restart
76
+ bun run src/cli/index.ts service path # where the unit file lives
77
+ ```
78
+
79
+ `service install` points `ExecStart` at the exact `bun` binary and package
80
+ checkout currently running the CLI, so re-running it after an upgrade (a new
81
+ `npm`/`bun` global install, or a fresh checkout) picks up the new path
82
+ immediately via `daemon-reload` + `enable` + `restart` — no manual `stop`
83
+ needed first.
84
+
56
85
  Once installed as a package, the same commands are available as `tickets`
57
86
  and `tickets-daemon` (see `bin` in `package.json`).
58
87
 
@@ -139,11 +168,26 @@ up the new credential — `buildRepositories()` runs once at daemon startup.
139
168
  `extensions/pi-tickets/` registers a single `tickets` tool for
140
169
  [pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
141
170
  `get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
142
- `backends`, `ledger_search`, `ledger_stats`). It talks to the same daemon
171
+ `backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
172
+ `focus_pause`, `focus_unpause`, `focus_clear`). It talks to the same daemon
143
173
  through the same authenticated RPC client the CLI uses — never a direct
144
- backend call or a direct SQLite open. OAuth login is deliberately **not** a
145
- tool action: approving access requires a human in a browser, which belongs
146
- in a terminal (`tickets auth login`), not an LLM tool call.
174
+ backend call or a direct SQLite open. OAuth login and daemon lifecycle
175
+ control are deliberately **not** exposed here (neither as a tool action nor
176
+ as the `/tickets` command below): approving OAuth access requires a human in
177
+ a browser, and stopping a shared daemon is an operational decision, not
178
+ something an LLM tool call or a casual keypress should trigger. Use
179
+ `tickets auth login`/`tickets daemon stop` from a terminal for those.
180
+
181
+ It also registers a `/tickets [query]` interactive TUI command (for the
182
+ human, not the LLM): a browsable list of every issue the daemon's ledger has
183
+ pooled across every configured backend in one flat list (no backend picker
184
+ needed). `↑↓` navigate, `enter` sets focus on the highlighted issue, `o`
185
+ opens its real web URL in a browser without closing the dialog, and `esc`
186
+ cancels. When a focus is already set, a "Clear current focus" row appears
187
+ first. A persistent footer status (`🎯 backend:key`, or `⏸` when paused)
188
+ shows the current focus at all times, refreshed on session start and after
189
+ every `tickets` tool call — so a focus the LLM sets via `focus_set` mid-
190
+ conversation shows up in the footer too, and vice versa.
147
191
 
148
192
  To use it:
149
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,9 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "daemon": "bun run src/daemon/main.ts",
23
- "test": "bun test",
23
+ "test": "bun test --path-ignore-patterns 'extensions/**'",
24
+ "test:extension": "cd extensions/pi-tickets && bun install && bun test",
25
+ "test:all": "npm run test && npm run test:extension",
24
26
  "typecheck": "tsc --noEmit"
25
27
  },
26
28
  "dependencies": {
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
14
14
  import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
15
15
  import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
16
16
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
17
+ import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
17
18
 
18
19
  function printJson(value: unknown): void {
19
20
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -31,7 +32,7 @@ async function withClient<T>(fn: (client: TicketsRpcClient) => Promise<T>): Prom
31
32
  }
32
33
 
33
34
  const program = new Command();
34
- program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.1.0");
35
+ program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.2.0");
35
36
 
36
37
  program
37
38
  .command("list")
@@ -156,6 +157,43 @@ program
156
157
  await withClient((client) => client.call("backends.list", {}));
157
158
  });
158
159
 
160
+ const focus = program.command("focus").description("track the single ticket you're currently working on, with its full URL");
161
+
162
+ focus
163
+ .command("set <ref>")
164
+ .description('focus a ticket, e.g. "jira:PROJ-42" or "github:#7" (resolves and stores its real web URL)')
165
+ .action(async (ref: string) => {
166
+ await withClient((client) => client.call("focus.set", { ref }));
167
+ });
168
+
169
+ focus
170
+ .command("get")
171
+ .description("show the currently focused ticket, if any")
172
+ .action(async () => {
173
+ await withClient((client) => client.call("focus.get", {}));
174
+ });
175
+
176
+ focus
177
+ .command("pause [reason]")
178
+ .description("pause the current focus without losing it (e.g. stepping away to do something else)")
179
+ .action(async (reason: string | undefined) => {
180
+ await withClient((client) => client.call("focus.pause", { reason }));
181
+ });
182
+
183
+ focus
184
+ .command("unpause")
185
+ .description("resume a paused focus")
186
+ .action(async () => {
187
+ await withClient((client) => client.call("focus.unpause", {}));
188
+ });
189
+
190
+ focus
191
+ .command("clear")
192
+ .description("clear the current focus")
193
+ .action(async () => {
194
+ await withClient((client) => client.call("focus.clear", {}));
195
+ });
196
+
159
197
  const daemon = program.command("daemon").description("manage the tickets daemon process");
160
198
 
161
199
  daemon
@@ -218,6 +256,44 @@ daemon
218
256
  }
219
257
  });
220
258
 
259
+ const service = program
260
+ .command("service")
261
+ .description("deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)");
262
+
263
+ service
264
+ .command("install")
265
+ .description("write, enable, and (re)start a tickets-daemon.service systemd --user unit pointed at this install")
266
+ .action(() => {
267
+ try {
268
+ const { unitPath } = installTicketsService();
269
+ printJson({ status: "installed", unitPath });
270
+ } catch (err) {
271
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
272
+ process.exitCode = 1;
273
+ }
274
+ });
275
+
276
+ for (const action of ["start", "stop", "restart", "status"] as const) {
277
+ service
278
+ .command(action)
279
+ .description(`systemctl --user ${action} tickets-daemon.service`)
280
+ .action(() => {
281
+ try {
282
+ systemctlTickets(action);
283
+ } catch (err) {
284
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
285
+ process.exitCode = 1;
286
+ }
287
+ });
288
+ }
289
+
290
+ service
291
+ .command("path")
292
+ .description("print where the systemd unit file would be written")
293
+ .action(() => {
294
+ printJson({ unitPath: systemdUnitPath() });
295
+ });
296
+
221
297
  const auth = program.command("auth").description("delegated OAuth login (device flow for GitHub/GitLab, authorization code for Jira)");
222
298
 
223
299
  auth
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Deploys the tickets daemon as a persistent systemd --user service, so it
3
+ * survives logout/reboot instead of only existing for as long as some CLI
4
+ * command's on-demand auto-spawn keeps it alive. Mirrors papyrus's own
5
+ * `papyrus service <install|start|stop|restart|status>` pattern exactly
6
+ * (see ~/Projects/papyrus/src/cli.ts) -- same shape, same systemctl --user
7
+ * verbs, same install order (write unit -> daemon-reload -> enable ->
8
+ * restart). Every side effect (file write, directory creation, systemctl
9
+ * invocation) is injectable so this is fully testable without touching a
10
+ * real filesystem or spawning a real systemctl process.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { resolveDaemonEntryPath } from "../client/tickets-client.js";
16
+ import { TICKETS_DAEMON_NAMES } from "../daemon/ops.js";
17
+
18
+ export interface SystemdUnitOptions {
19
+ bunBin: string;
20
+ daemonMainPath: string;
21
+ }
22
+
23
+ export function renderSystemdUnit(options: SystemdUnitOptions): string {
24
+ return `[Unit]
25
+ Description=Tickets daemon -- unified GitHub/GitLab/Jira issue tracking
26
+ After=default.target
27
+
28
+ [Service]
29
+ Type=simple
30
+ ExecStart=${options.bunBin} run ${options.daemonMainPath}
31
+ Restart=always
32
+ RestartSec=2
33
+
34
+ [Install]
35
+ WantedBy=default.target
36
+ `;
37
+ }
38
+
39
+ /** XDG_CONFIG_HOME/systemd/user/tickets-daemon.service, falling back to ~/.config like systemd itself does. */
40
+ export function systemdUnitPath(env: Record<string, string | undefined> = process.env): string {
41
+ const configHome = env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config");
42
+ return join(configHome, "systemd", "user", TICKETS_DAEMON_NAMES.systemdUnitName);
43
+ }
44
+
45
+ export type CommandRunner = (command: string, args: string[]) => void;
46
+
47
+ const defaultRunner: CommandRunner = (command, args) => {
48
+ try {
49
+ execFileSync(command, args, { stdio: "inherit" });
50
+ } catch (err) {
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ throw new Error(`${command} ${args.join(" ")} failed (is systemd --user available? this feature is Linux-only): ${message}`);
53
+ }
54
+ };
55
+
56
+ export type SystemctlAction = "start" | "stop" | "restart" | "status" | "enable" | "daemon-reload";
57
+
58
+ /** Always targets the tickets unit name under --user scope; daemon-reload takes no unit argument. */
59
+ export function systemctlTickets(action: SystemctlAction, runner: CommandRunner = defaultRunner): void {
60
+ const args = action === "daemon-reload" ? ["--user", "daemon-reload"] : ["--user", action, TICKETS_DAEMON_NAMES.systemdUnitName];
61
+ runner("systemctl", args);
62
+ }
63
+
64
+ export interface InstallOptions {
65
+ bunBin?: string;
66
+ daemonMainPath?: string;
67
+ env?: Record<string, string | undefined>;
68
+ runner?: CommandRunner;
69
+ writeFile?: (path: string, content: string) => void;
70
+ ensureDir?: (path: string) => void;
71
+ }
72
+
73
+ /**
74
+ * Writes the unit file, then daemon-reload -> enable -> restart, in that
75
+ * order -- systemd must see the file before enable/restart can act on it,
76
+ * and restart (not start) so re-running install after an upgrade picks up
77
+ * a changed ExecStart path immediately rather than requiring a manual stop.
78
+ */
79
+ export function installTicketsService(opts: InstallOptions = {}): { unitPath: string } {
80
+ const unitPath = systemdUnitPath(opts.env);
81
+ const ensureDir = opts.ensureDir ?? ((dir: string) => mkdirSync(dir, { recursive: true }));
82
+ const writeFile = opts.writeFile ?? writeFileSync;
83
+ const runner = opts.runner ?? defaultRunner;
84
+
85
+ ensureDir(dirname(unitPath));
86
+ writeFile(
87
+ unitPath,
88
+ renderSystemdUnit({
89
+ bunBin: opts.bunBin ?? process.execPath,
90
+ daemonMainPath: opts.daemonMainPath ?? resolveDaemonEntryPath(),
91
+ }),
92
+ );
93
+ systemctlTickets("daemon-reload", runner);
94
+ systemctlTickets("enable", runner);
95
+ systemctlTickets("restart", runner);
96
+ return { unitPath };
97
+ }
@@ -29,10 +29,14 @@ async function isAlive(handle: DaemonHandle, token: string): Promise<boolean> {
29
29
  }
30
30
  }
31
31
 
32
- function spawnDaemon(): void {
32
+ /** Absolute path to the daemon's real entry point, resolved from this package's own root. Used both to spawn it on demand and to point a systemd unit's ExecStart at it (see cli/systemd-service.ts). */
33
+ export function resolveDaemonEntryPath(): string {
33
34
  const root = packageRoot(dirname(fileURLToPath(import.meta.url)));
34
- const entry = join(root, "src", "daemon", "main.ts");
35
- const child = spawn("bun", ["run", entry], { detached: true, stdio: "ignore" });
35
+ return join(root, "src", "daemon", "main.ts");
36
+ }
37
+
38
+ function spawnDaemon(): void {
39
+ const child = spawn("bun", ["run", resolveDaemonEntryPath()], { detached: true, stdio: "ignore" });
36
40
  child.unref();
37
41
  }
38
42
 
@@ -13,6 +13,7 @@ import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
13
13
  import { TicketService } from "../application/service.js";
14
14
  import { buildRepositories, type Config, loadConfig } from "../config/config.js";
15
15
  import type { IssueRepository } from "../ports/repository.js";
16
+ import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
16
17
  import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
17
18
  import { TICKETS_DAEMON_NAMES } from "./ops.js";
18
19
  import { buildApp } from "./server.js";
@@ -39,6 +40,7 @@ export interface BootstrapOptions {
39
40
  export interface BootstrappedDaemon {
40
41
  db: Database;
41
42
  ledger: Ledger;
43
+ focusStore: FocusStore;
42
44
  service: TicketService;
43
45
  options: StartDaemonOptions;
44
46
  }
@@ -49,8 +51,9 @@ const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
49
51
  export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
50
52
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
51
53
  const token = ensureAuthToken(paths.token, "Tickets");
52
- const db = openSqliteWithPragmas(paths.database, { migrations: LEDGER_MIGRATIONS });
54
+ const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
53
55
  const ledger = new Ledger(db);
56
+ const focusStore = new FocusStore(db);
54
57
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
55
58
  const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
56
59
  const service = new TicketService(repos);
@@ -72,6 +75,7 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
72
75
  buildApp({
73
76
  service,
74
77
  ledger,
78
+ focusStore,
75
79
  token,
76
80
  version,
77
81
  logger,
@@ -82,5 +86,5 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
82
86
  },
83
87
  };
84
88
 
85
- return { db, ledger, service, options };
89
+ return { db, ledger, focusStore, service, options };
86
90
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Focus — the single ticket currently being worked on, independent of any
3
+ * one CLI invocation or tool call. Unlike the Ledger (a cache of every issue
4
+ * the daemon has ever seen), Focus is a pointer: one ref, its resolved title
5
+ * and full web URL, and whether work on it is active or paused. Persisted
6
+ * so it survives daemon restarts. A singleton by design — there is never
7
+ * more than one ticket in focus at a time, so setting focus always replaces
8
+ * whatever was there, the same way switching your attention to a different
9
+ * ticket in real life replaces what you were just looking at.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import type { Migration } from "@danypops/daemon-kit/storage";
13
+
14
+ export const FOCUS_MIGRATIONS: Migration[] = [
15
+ {
16
+ version: 2,
17
+ up: (db) => {
18
+ db.exec(`
19
+ CREATE TABLE ticket_focus (
20
+ id INTEGER PRIMARY KEY CHECK (id = 1),
21
+ ref TEXT NOT NULL,
22
+ title TEXT NOT NULL,
23
+ url TEXT NOT NULL,
24
+ status TEXT NOT NULL,
25
+ pause_reason TEXT,
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ `);
29
+ },
30
+ },
31
+ ];
32
+
33
+ export type FocusStatus = "active" | "paused";
34
+
35
+ export interface TicketFocusState {
36
+ ref: string;
37
+ title: string;
38
+ url: string;
39
+ status: FocusStatus;
40
+ updatedAt: string;
41
+ pauseReason?: string;
42
+ }
43
+
44
+ /** Invalid focus state transitions (nothing focused, double-pause, double-unpause) or a resolved issue with no URL to focus on. Maps to HTTP 400, not 500. */
45
+ export class FocusError extends Error {
46
+ constructor(message: string) {
47
+ super(message);
48
+ this.name = "FocusError";
49
+ }
50
+ }
51
+
52
+ interface FocusRow {
53
+ ref: string;
54
+ title: string;
55
+ url: string;
56
+ status: FocusStatus;
57
+ pause_reason: string | null;
58
+ updated_at: string;
59
+ }
60
+
61
+ function rowToState(row: FocusRow): TicketFocusState {
62
+ return {
63
+ ref: row.ref,
64
+ title: row.title,
65
+ url: row.url,
66
+ status: row.status,
67
+ updatedAt: row.updated_at,
68
+ ...(row.pause_reason ? { pauseReason: row.pause_reason } : {}),
69
+ };
70
+ }
71
+
72
+ export class FocusStore {
73
+ constructor(private readonly db: Database) {}
74
+
75
+ get(): TicketFocusState | undefined {
76
+ const row = this.db
77
+ .query("SELECT ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE id = 1")
78
+ .get() as FocusRow | null;
79
+ return row ? rowToState(row) : undefined;
80
+ }
81
+
82
+ /** Always lands "active" and drops any prior pause reason: switching focus onto a different ticket is not the same as resuming a pause on the old one. */
83
+ set(ref: string, title: string, url: string): TicketFocusState {
84
+ const updatedAt = new Date().toISOString();
85
+ this.db
86
+ .query(
87
+ `INSERT INTO ticket_focus (id, ref, title, url, status, pause_reason, updated_at)
88
+ VALUES (1, $ref, $title, $url, 'active', NULL, $updatedAt)
89
+ ON CONFLICT(id) DO UPDATE SET
90
+ ref = excluded.ref, title = excluded.title, url = excluded.url,
91
+ status = 'active', pause_reason = NULL, updated_at = excluded.updated_at`,
92
+ )
93
+ .run({ $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
94
+ return { ref, title, url, status: "active", updatedAt };
95
+ }
96
+
97
+ pause(reason?: string): TicketFocusState {
98
+ const current = this.get();
99
+ if (!current) throw new FocusError("no ticket is currently focused");
100
+ if (current.status === "paused") throw new FocusError(`focus on "${current.ref}" is already paused`);
101
+ return this.transition("paused", reason);
102
+ }
103
+
104
+ unpause(): TicketFocusState {
105
+ const current = this.get();
106
+ if (!current) throw new FocusError("no ticket is currently focused");
107
+ if (current.status === "active") throw new FocusError(`focus on "${current.ref}" is already active`);
108
+ return this.transition("active", undefined);
109
+ }
110
+
111
+ /** Returns whether a focus existed to clear (idempotent either way). */
112
+ clear(): boolean {
113
+ const existed = this.get() !== undefined;
114
+ this.db.exec("DELETE FROM ticket_focus WHERE id = 1");
115
+ return existed;
116
+ }
117
+
118
+ private transition(status: FocusStatus, reason: string | undefined): TicketFocusState {
119
+ const updatedAt = new Date().toISOString();
120
+ this.db
121
+ .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE id = 1")
122
+ .run({ $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
123
+ // Non-null: transition() is only ever called right after get() confirmed a row exists.
124
+ return this.get()!;
125
+ }
126
+ }
package/src/daemon/ops.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * import from either side without pulling in bun:sqlite or Bun.serve.
6
6
  */
7
7
  import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
8
+ import type { TicketFocusState } from "./focus.js";
8
9
 
9
10
  export type TicketOperation =
10
11
  | "backends.list"
@@ -18,6 +19,11 @@ export type TicketOperation =
18
19
  | "issue.comment_add"
19
20
  | "ledger.search"
20
21
  | "ledger.stats"
22
+ | "focus.set"
23
+ | "focus.get"
24
+ | "focus.pause"
25
+ | "focus.unpause"
26
+ | "focus.clear"
21
27
  | "daemon.shutdown";
22
28
 
23
29
  export interface TicketOpInputs extends Record<TicketOperation, unknown> {
@@ -32,6 +38,11 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
32
38
  "issue.comment_add": { ref: string; body: string };
33
39
  "ledger.search": { query: string; limit?: number };
34
40
  "ledger.stats": Record<string, never>;
41
+ "focus.set": { ref: string };
42
+ "focus.get": Record<string, never>;
43
+ "focus.pause": { reason?: string };
44
+ "focus.unpause": Record<string, never>;
45
+ "focus.clear": Record<string, never>;
35
46
  "daemon.shutdown": Record<string, never>;
36
47
  }
37
48
 
@@ -47,6 +58,11 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
47
58
  "issue.comment_add": { comment: Comment };
48
59
  "ledger.search": { issues: Issue[] };
49
60
  "ledger.stats": { backends: { backend: string; count: number }[] };
61
+ "focus.set": { focus: TicketFocusState };
62
+ "focus.get": { focus: TicketFocusState | null };
63
+ "focus.pause": { focus: TicketFocusState };
64
+ "focus.unpause": { focus: TicketFocusState };
65
+ "focus.clear": { cleared: boolean };
50
66
  "daemon.shutdown": { stopping: true };
51
67
  }
52
68
 
@@ -62,6 +78,11 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
62
78
  "issue.comment_add",
63
79
  "ledger.search",
64
80
  "ledger.stats",
81
+ "focus.set",
82
+ "focus.get",
83
+ "focus.pause",
84
+ "focus.unpause",
85
+ "focus.clear",
65
86
  "daemon.shutdown",
66
87
  ];
67
88
 
@@ -8,12 +8,15 @@ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBear
8
8
  import type { Logger } from "@danypops/daemon-kit/logging";
9
9
  import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
10
10
  import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
11
+ import { parseRef } from "../domain/issue.js";
12
+ import { FocusError, type FocusStore } from "./focus.js";
11
13
  import type { Ledger } from "./ledger.js";
12
14
  import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
13
15
 
14
16
  export interface TicketsAppDeps {
15
17
  service: TicketService;
16
18
  ledger: Ledger;
19
+ focusStore: FocusStore;
17
20
  token: string;
18
21
  version: string;
19
22
  logger?: Logger;
@@ -44,6 +47,22 @@ const handlers: { [Op in TicketOperation]: Handler<Op> } = {
44
47
  "issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
45
48
  "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit) }),
46
49
  "ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
50
+ "focus.set": async (deps, input) => {
51
+ // Ledger-first: focusing a ticket already pooled locally needs no live
52
+ // backend call. Otherwise fall back to a live get (also validates the
53
+ // ref actually exists) and opportunistically warm the ledger with it,
54
+ // since a ticket you're about to focus on is exactly the kind of issue
55
+ // worth having cached locally.
56
+ const cached = deps.ledger.get(input.ref);
57
+ const issue = cached ?? (await deps.service.get(input.ref));
58
+ if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
59
+ if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
60
+ return { focus: deps.focusStore.set(input.ref, issue.title, issue.url) };
61
+ },
62
+ "focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
63
+ "focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
64
+ "focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
65
+ "focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
47
66
  "daemon.shutdown": async (deps) => {
48
67
  // Deferred so this handler's own response has already been handed back
49
68
  // to Bun.serve before the process starts tearing down.
@@ -58,7 +77,7 @@ function isTicketOperation(value: unknown): value is TicketOperation {
58
77
 
59
78
  function statusFor(error: unknown): number {
60
79
  if (error instanceof IssueNotFoundError) return 404;
61
- if (error instanceof UnknownBackendError || error instanceof NotSupportedError) return 400;
80
+ if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
62
81
  if (error instanceof AuthRequiredError) return 422;
63
82
  return 500;
64
83
  }