9to5 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "9to5",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Cron for Claude Code — schedule and automate Claude Code tasks",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,11 +12,7 @@
12
12
  "bin": {
13
13
  "9to5": "./packages/cli/src/index.ts"
14
14
  },
15
- "files": [
16
- "packages/cli/src",
17
- "packages/core/src",
18
- "packages/tui/src"
19
- ],
15
+ "files": ["packages/cli/src", "packages/core/src", "packages/tui/src"],
20
16
  "scripts": {
21
17
  "dev": "bun run packages/cli/src/index.ts",
22
18
  "build": "bun build --compile packages/cli/src/index.ts --outfile 9to5",
@@ -10,17 +10,20 @@ export function registerEdit(program: Command): void {
10
10
  .option("--cwd <dir>", "Update working directory")
11
11
  .option("--rrule <rule>", "Update recurrence rule")
12
12
  .option("--model <model>", "Update model (sonnet, opus, haiku)")
13
- .option("--max-budget-usd <amount>", "Update max budget in USD", Number.parseFloat)
14
- .option("--allowed-tools <tools>", "Update allowed tools (comma-separated)")
15
13
  .option(
16
- "--system-prompt <prompt>",
17
- "Update system prompt",
14
+ "--max-budget-usd <amount>",
15
+ "Update max budget in USD",
16
+ Number.parseFloat,
18
17
  )
18
+ .option("--allowed-tools <tools>", "Update allowed tools (comma-separated)")
19
+ .option("--system-prompt <prompt>", "Update system prompt")
19
20
  .option("--status <status>", "Set status (active, paused)")
20
21
  .action((id: string, opts) => {
21
22
  const db = getDb();
22
23
 
23
- const row = db.query("SELECT * FROM automations WHERE id = ?").get(id) as Automation | null;
24
+ const row = db
25
+ .query("SELECT * FROM automations WHERE id = ?")
26
+ .get(id) as Automation | null;
24
27
  if (!row) {
25
28
  console.error(`Automation ${id} not found.`);
26
29
  process.exit(1);
@@ -66,7 +69,9 @@ export function registerEdit(program: Command): void {
66
69
  if (opts.allowedTools != null) {
67
70
  sets.push("allowed_tools = ?");
68
71
  values.push(
69
- JSON.stringify(opts.allowedTools.split(",").map((t: string) => t.trim())),
72
+ JSON.stringify(
73
+ opts.allowedTools.split(",").map((t: string) => t.trim()),
74
+ ),
70
75
  );
71
76
  }
72
77
 
@@ -82,10 +87,21 @@ export function registerEdit(program: Command): void {
82
87
  }
83
88
  sets.push("status = ?");
84
89
  values.push(opts.status);
90
+
91
+ // Recalculate next_run_at when resuming an automation with an rrule
92
+ const rrule = opts.rrule ?? row.rrule;
93
+ if (opts.status === "active" && rrule) {
94
+ const rule = RRule.fromString(`RRULE:${rrule}`);
95
+ const next = rule.after(new Date());
96
+ sets.push("next_run_at = ?");
97
+ values.push(next ? next.getTime() : null);
98
+ }
85
99
  }
86
100
 
87
101
  if (sets.length === 0) {
88
- console.error("No fields to update. Use --name, --prompt, --model, etc.");
102
+ console.error(
103
+ "No fields to update. Use --name, --prompt, --model, etc.",
104
+ );
89
105
  process.exit(1);
90
106
  }
91
107
 
@@ -93,10 +109,7 @@ export function registerEdit(program: Command): void {
93
109
  values.push(Date.now());
94
110
  values.push(id);
95
111
 
96
- db.run(
97
- `UPDATE automations SET ${sets.join(", ")} WHERE id = ?`,
98
- values,
99
- );
112
+ db.run(`UPDATE automations SET ${sets.join(", ")} WHERE id = ?`, values);
100
113
 
101
114
  console.log(`Updated automation ${id} (${opts.name ?? row.name})`);
102
115
  });
@@ -0,0 +1,73 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Command } from "commander";
5
+
6
+ const INSTRUCTIONS = `
7
+ ## Task Scheduling (9to5)
8
+
9
+ Use **9to5** to schedule and manage recurring Claude Code tasks. Run \`9to5 --help\` or \`9to5 <command> --help\` for full usage.
10
+
11
+ Key commands: \`add\`, \`list\`, \`run\`, \`runs\`, \`inbox\`, \`edit\`, \`remove\`, \`start\`, \`stop\`, \`export\`, \`import\`, \`ui\`.
12
+
13
+ ### Options for \`add\` and \`edit\`
14
+
15
+ | Option | Default |
16
+ |--------|---------|
17
+ | \`--prompt <prompt>\` | Required |
18
+ | \`--cwd <dir>\` | Current directory |
19
+ | \`--rrule <rule>\` | None (manual only) |
20
+ | \`--model <model>\` | sonnet |
21
+ | \`--max-budget-usd <amount>\` | None |
22
+ | \`--allowed-tools <tools>\` | All tools |
23
+ | \`--system-prompt <prompt>\` | None |
24
+
25
+ ### Scheduling (rrule)
26
+
27
+ The \`--rrule\` flag uses RFC 5545 recurrence rules:
28
+ - Daily at 9 AM: \`FREQ=DAILY;BYHOUR=9\`
29
+ - Every 4 hours: \`FREQ=HOURLY;INTERVAL=4\`
30
+ - Weekdays at 10 AM: \`FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=10\`
31
+ - Mondays at 9 AM: \`FREQ=WEEKLY;BYDAY=MO;BYHOUR=9\`
32
+
33
+ Without \`--rrule\`, automations are manual-only (\`9to5 run <id>\`).
34
+ `.trim();
35
+
36
+ const MARKER = "## Task Scheduling (9to5)";
37
+
38
+ export function registerOnboard(program: Command): void {
39
+ program
40
+ .command("onboard")
41
+ .description("Add 9to5 instructions to ~/.claude/CLAUDE.md")
42
+ .action(() => {
43
+ const claudeDir = join(homedir(), ".claude");
44
+ const claudeMd = join(claudeDir, "CLAUDE.md");
45
+
46
+ if (!existsSync(claudeDir)) {
47
+ mkdirSync(claudeDir, { recursive: true });
48
+ }
49
+
50
+ let existingContent = "";
51
+ if (existsSync(claudeMd)) {
52
+ existingContent = readFileSync(claudeMd, "utf-8");
53
+ }
54
+
55
+ if (existingContent.includes(MARKER)) {
56
+ console.log("✓ Already onboarded");
57
+ console.log(` ${claudeMd}`);
58
+ return;
59
+ }
60
+
61
+ if (existingContent) {
62
+ const newContent = `${existingContent.trimEnd()}\n\n${INSTRUCTIONS}\n`;
63
+ writeFileSync(claudeMd, newContent);
64
+ } else {
65
+ writeFileSync(claudeMd, `${INSTRUCTIONS}\n`);
66
+ }
67
+
68
+ console.log(`✓ Added 9to5 instructions to ${claudeMd}`);
69
+ console.log();
70
+ console.log("Your agent now knows how to use 9to5!");
71
+ console.log();
72
+ });
73
+ }
@@ -6,6 +6,7 @@ import { registerExport } from "./commands/export.ts";
6
6
  import { registerImport } from "./commands/import.ts";
7
7
  import { registerInbox } from "./commands/inbox.ts";
8
8
  import { registerList } from "./commands/list.ts";
9
+ import { registerOnboard } from "./commands/onboard.ts";
9
10
  import { registerRemove } from "./commands/remove.ts";
10
11
  import { registerRun } from "./commands/run.ts";
11
12
  import { registerRuns } from "./commands/runs.ts";
@@ -22,6 +23,7 @@ registerEdit(program);
22
23
  registerExport(program);
23
24
  registerImport(program);
24
25
  registerList(program);
26
+ registerOnboard(program);
25
27
  registerRemove(program);
26
28
  registerRun(program);
27
29
  registerRuns(program);
@@ -1,4 +1,4 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -10,3 +10,15 @@ export const DAEMON_POLL_INTERVAL_MS = 30_000;
10
10
  export function ensureDataDir(): void {
11
11
  mkdirSync(DATA_DIR, { recursive: true });
12
12
  }
13
+
14
+ export function isDaemonRunning(): boolean {
15
+ if (!existsSync(PID_FILE)) return false;
16
+ try {
17
+ const pid = Number.parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
18
+ if (Number.isNaN(pid)) return false;
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
@@ -4,6 +4,7 @@ export {
4
4
  PID_FILE,
5
5
  DAEMON_POLL_INTERVAL_MS,
6
6
  ensureDataDir,
7
+ isDaemonRunning,
7
8
  } from "./config.ts";
8
9
  export type {
9
10
  Automation,
@@ -1,5 +1,5 @@
1
1
  import type { Automation, Run } from "@9to5/core";
2
- import { getDb } from "@9to5/core";
2
+ import { getDb, isDaemonRunning } from "@9to5/core";
3
3
  import { useKeyboard, useRenderer } from "@opentui/react";
4
4
  import { useState } from "react";
5
5
  import { AutomationDetail } from "./components/AutomationDetail.tsx";
@@ -34,13 +34,16 @@ export function App() {
34
34
  const renderer = useRenderer();
35
35
  const db = getDb();
36
36
  const [view, setView] = useState<View>("automations");
37
- const [selectedAutomation, setSelectedAutomation] = useState<Automation | null>(null);
37
+ const [selectedAutomation, setSelectedAutomation] =
38
+ useState<Automation | null>(null);
38
39
  const [selectedRun, setSelectedRun] = useState<Run | null>(null);
39
40
  const { message: notification, notify } = useNotification();
40
41
 
41
42
  const { data: stats } = useDbQuery(() => {
42
43
  const { count: running } = db
43
- .query("SELECT COUNT(*) as count FROM runs WHERE status IN ('running','pending')")
44
+ .query(
45
+ "SELECT COUNT(*) as count FROM runs WHERE status IN ('running','pending')",
46
+ )
44
47
  .get() as { count: number };
45
48
  const { count: failed } = db
46
49
  .query(
@@ -51,13 +54,15 @@ export function App() {
51
54
  .get() as { count: number };
52
55
  const { next: nextRunAt } = db
53
56
  .query(
54
- "SELECT MIN(next_run_at) as next FROM automations WHERE status = 'active' AND next_run_at IS NOT NULL",
57
+ "SELECT MIN(next_run_at) as next FROM automations WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at > ?",
55
58
  )
56
- .get() as { next: number | null };
57
- return { running, failed, nextRunAt };
59
+ .get(Date.now()) as { next: number | null };
60
+ const daemonUp = isDaemonRunning();
61
+ return { running, failed, nextRunAt, daemonUp };
58
62
  });
59
63
 
60
64
  const statParts: string[] = [];
65
+ if (!stats.daemonUp) statParts.push("daemon stopped");
61
66
  if (stats.running > 0) statParts.push(`${stats.running} running`);
62
67
  if (stats.failed > 0) statParts.push(`${stats.failed} failed`);
63
68
  const nextIn = formatCountdown(stats.nextRunAt);
@@ -81,109 +86,120 @@ export function App() {
81
86
 
82
87
  const leftTitle =
83
88
  view === "automations" ? (
84
- <span fg="cyan"><strong>Automations</strong></span>
89
+ <span fg="cyan">
90
+ <strong>Automations</strong>
91
+ </span>
85
92
  ) : (
86
93
  <>
87
94
  <span fg="cyan">{"← "}</span>
88
- <span fg="cyan"><strong>{selectedAutomation?.name ?? ""}</strong></span>
95
+ <span fg="cyan">
96
+ <strong>{selectedAutomation?.name ?? ""}</strong>
97
+ </span>
89
98
  </>
90
99
  );
91
100
 
92
101
  return (
93
- <box
94
- flexDirection="column"
95
- width="100%"
96
- height="100%"
97
- position="relative"
98
- >
99
- <box
100
- flexDirection="column"
101
- flexGrow={1}
102
- border
103
- borderStyle="rounded"
104
- borderColor="#444"
105
- >
106
- {/* Header */}
107
- <box height={1} flexDirection="row" paddingLeft={1} paddingRight={1}>
108
- <text>
109
- <span fg="#c084fc">
110
- <strong>{"◆ 9to5"}</strong>
111
- </span>
112
- </text>
113
- <box flexGrow={1} />
114
- {statParts.length > 0 ? (
102
+ <box flexDirection="column" width="100%" height="100%" position="relative">
103
+ <box
104
+ flexDirection="column"
105
+ flexGrow={1}
106
+ border
107
+ borderStyle="rounded"
108
+ borderColor="#444"
109
+ >
110
+ {/* Header */}
111
+ <box height={1} flexDirection="row" paddingLeft={1} paddingRight={1}>
115
112
  <text>
116
- <span fg="#666">{statParts.join(" · ")}</span>
113
+ <span fg="#c084fc">
114
+ <strong>{"◆ 9to5"}</strong>
115
+ </span>
117
116
  </text>
118
- ) : null}
119
- </box>
120
-
121
- {/* 2-Column Content */}
122
- <box flexDirection="row" flexGrow={1}>
123
- {/* Left Panel */}
124
- <box
125
- flexDirection="column"
126
- width={28}
127
- overflow="hidden"
128
- >
129
- <box height={1} paddingLeft={1}>
130
- <text>{leftTitle}</text>
131
- </box>
132
- {view === "automations" ? (
133
- <AutomationList
134
- focused={true}
135
- onSelect={setSelectedAutomation}
136
- onNotify={notify}
137
- onDrillDown={handleDrillDown}
138
- />
139
- ) : selectedAutomation ? (
140
- <RunList
141
- key={selectedAutomation.id}
142
- automationId={selectedAutomation.id}
143
- focused={true}
144
- onSelect={setSelectedRun}
145
- onNotify={notify}
146
- onBack={handleBack}
147
- />
117
+ <box flexGrow={1} />
118
+ {statParts.length > 0 ? (
119
+ <text>
120
+ {!stats.daemonUp ? (
121
+ <span fg="yellow">{"daemon stopped"}</span>
122
+ ) : null}
123
+ {!stats.daemonUp && statParts.length > 1 ? (
124
+ <span fg="#666">{" · "}</span>
125
+ ) : null}
126
+ <span fg="#666">
127
+ {(stats.daemonUp ? statParts : statParts.slice(1)).join(" · ")}
128
+ </span>
129
+ </text>
148
130
  ) : null}
149
131
  </box>
150
132
 
151
- {/* Separator */}
152
- <box width={1} backgroundColor="#444" />
153
-
154
- {/* Right Panel - Detail */}
155
- <box flexDirection="column" flexGrow={1} paddingLeft={1}>
156
- {view === "automations" && selectedAutomation ? (
157
- <AutomationDetail automation={selectedAutomation} />
158
- ) : view === "runs" && selectedRun ? (
159
- <RunDetail
160
- run={selectedRun}
161
- automationName={selectedAutomation?.name}
162
- />
163
- ) : (
164
- <box
165
- flexGrow={1}
166
- justifyContent="center"
167
- alignItems="center"
168
- >
169
- <text>
170
- <span fg="#666">Select an item to view details</span>
171
- </text>
133
+ {/* 2-Column Content */}
134
+ <box flexDirection="row" flexGrow={1}>
135
+ {/* Left Panel */}
136
+ <box flexDirection="column" width={28} overflow="hidden">
137
+ <box height={1} paddingLeft={1}>
138
+ <text>{leftTitle}</text>
172
139
  </box>
173
- )}
174
- </box>
175
- </box>
140
+ {view === "automations" ? (
141
+ <AutomationList
142
+ focused={true}
143
+ onSelect={setSelectedAutomation}
144
+ onNotify={notify}
145
+ onDrillDown={handleDrillDown}
146
+ />
147
+ ) : selectedAutomation ? (
148
+ <RunList
149
+ key={selectedAutomation.id}
150
+ automationId={selectedAutomation.id}
151
+ focused={true}
152
+ onSelect={setSelectedRun}
153
+ onNotify={notify}
154
+ onBack={handleBack}
155
+ />
156
+ ) : null}
157
+ </box>
176
158
 
177
- {/* Status Bar */}
178
- <StatusBar hints={view === "runs" ? RUNS_HINTS : [
179
- { k: "↑↓", label: "navigate" },
180
- { k: "→/⏎", label: "runs" },
181
- { k: "r", label: "run" },
182
- { k: "p", label: selectedAutomation?.status === "active" ? "pause" : "resume" },
183
- { k: "dd", label: "delete" },
184
- ]} notification={notification} />
185
- </box>
159
+ {/* Separator */}
160
+ <box width={1} backgroundColor="#444" />
161
+
162
+ {/* Right Panel - Detail */}
163
+ <box flexDirection="column" flexGrow={1} paddingLeft={1}>
164
+ {view === "automations" && selectedAutomation ? (
165
+ <AutomationDetail automation={selectedAutomation} />
166
+ ) : view === "runs" && selectedRun ? (
167
+ <RunDetail
168
+ run={selectedRun}
169
+ automationName={selectedAutomation?.name}
170
+ />
171
+ ) : (
172
+ <box flexGrow={1} justifyContent="center" alignItems="center">
173
+ <text>
174
+ <span fg="#666">Select an item to view details</span>
175
+ </text>
176
+ </box>
177
+ )}
178
+ </box>
179
+ </box>
186
180
 
181
+ {/* Status Bar */}
182
+ <StatusBar
183
+ hints={
184
+ view === "runs"
185
+ ? RUNS_HINTS
186
+ : [
187
+ { k: "↑↓", label: "navigate" },
188
+ { k: "→/⏎", label: "runs" },
189
+ { k: "r", label: "run" },
190
+ {
191
+ k: "p",
192
+ label:
193
+ selectedAutomation?.status === "active"
194
+ ? "pause"
195
+ : "resume",
196
+ },
197
+ { k: "dd", label: "delete" },
198
+ ]
199
+ }
200
+ notification={notification}
201
+ />
202
+ </box>
187
203
  </box>
188
204
  );
189
205
  }
@@ -1,12 +1,12 @@
1
1
  import type { Automation, Run } from "@9to5/core";
2
2
  import { getDb } from "@9to5/core";
3
3
  import { useDbQuery } from "../hooks/useDbQuery.ts";
4
+ import { useSpinner } from "../hooks/useSpinner.ts";
4
5
  import { Field } from "./Field.tsx";
5
6
  import { Section } from "./Section.tsx";
6
7
 
7
8
  const STATUS_STYLE: Record<string, { symbol: string; color: string }> = {
8
9
  pending: { symbol: "◦", color: "yellow" },
9
- running: { symbol: "⟳", color: "#5599ff" },
10
10
  completed: { symbol: "✓", color: "green" },
11
11
  failed: { symbol: "✗", color: "red" },
12
12
  };
@@ -26,9 +26,7 @@ function formatDuration(ms: number | null): string {
26
26
  return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
27
27
  }
28
28
 
29
- export function AutomationDetail({
30
- automation,
31
- }: { automation: Automation }) {
29
+ export function AutomationDetail({ automation }: { automation: Automation }) {
32
30
  const a = automation;
33
31
  const db = getDb();
34
32
 
@@ -44,6 +42,9 @@ export function AutomationDetail({
44
42
  .all(a.id) as Run[],
45
43
  );
46
44
 
45
+ const hasRunning = recentRuns.some((r) => r.status === "running");
46
+ const spinnerFrame = useSpinner(hasRunning);
47
+
47
48
  const statusColor = a.status === "active" ? "green" : "yellow";
48
49
  const statusSymbol = a.status === "active" ? "●" : "○";
49
50
 
@@ -65,24 +66,18 @@ export function AutomationDetail({
65
66
  <Field label="Schedule" value={a.rrule} />
66
67
  <Field
67
68
  label="Budget"
68
- value={
69
- a.max_budget_usd != null ? `$${a.max_budget_usd}` : null
70
- }
69
+ value={a.max_budget_usd != null ? `$${a.max_budget_usd}` : null}
71
70
  />
72
71
  <Field
73
72
  label="Next Run"
74
73
  value={
75
- a.next_run_at
76
- ? new Date(a.next_run_at).toLocaleString()
77
- : null
74
+ a.next_run_at ? new Date(a.next_run_at).toLocaleString() : null
78
75
  }
79
76
  />
80
77
  <Field
81
78
  label="Last Run"
82
79
  value={
83
- a.last_run_at
84
- ? new Date(a.last_run_at).toLocaleString()
85
- : null
80
+ a.last_run_at ? new Date(a.last_run_at).toLocaleString() : null
86
81
  }
87
82
  />
88
83
  </Section>
@@ -110,10 +105,16 @@ export function AutomationDetail({
110
105
  {recentRuns.length > 0 ? (
111
106
  <Section title="Recent Runs">
112
107
  {recentRuns.map((run) => {
113
- const st = STATUS_STYLE[run.status] ?? STATUS_STYLE.pending;
108
+ const st =
109
+ run.status === "running"
110
+ ? { symbol: spinnerFrame, color: "#5599ff" }
111
+ : (STATUS_STYLE[run.status] ?? STATUS_STYLE.pending);
114
112
  const duration = formatDuration(run.duration_ms);
115
- const cost = run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "";
116
- const parts = [timeAgo(run.created_at), duration, cost].filter(Boolean).join(" · ");
113
+ const cost =
114
+ run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "";
115
+ const parts = [timeAgo(run.created_at), duration, cost]
116
+ .filter(Boolean)
117
+ .join(" · ");
117
118
 
118
119
  return (
119
120
  <text key={run.id}>
@@ -1,9 +1,16 @@
1
- import { type Automation, executeRun, generateId, getDb } from "@9to5/core";
1
+ import {
2
+ type Automation,
3
+ RRule,
4
+ executeRun,
5
+ generateId,
6
+ getDb,
7
+ } from "@9to5/core";
2
8
  import { useKeyboard } from "@opentui/react";
3
9
  import { useCallback, useEffect } from "react";
4
10
  import { useDoubleTap } from "../hooks/useConfirm.ts";
5
11
  import { useDbQuery } from "../hooks/useDbQuery.ts";
6
12
  import { useListNav } from "../hooks/useListNav.ts";
13
+ import { useSpinner } from "../hooks/useSpinner.ts";
7
14
  import { ListItem } from "./ListItem.tsx";
8
15
 
9
16
  const STATUS_ICON: Record<string, { symbol: string; color: string }> = {
@@ -49,7 +56,12 @@ export function AutomationList({
49
56
  .all() as AutomationRow[],
50
57
  );
51
58
 
52
- const { selectedIndex, setSelectedIndex } = useListNav(automations.length, focused);
59
+ const { selectedIndex, setSelectedIndex } = useListNav(
60
+ automations.length,
61
+ focused,
62
+ );
63
+ const hasRunning = automations.some((a) => a.running_count > 0);
64
+ const spinnerFrame = useSpinner(hasRunning);
53
65
 
54
66
  const selected = automations[selectedIndex] as AutomationRow | undefined;
55
67
 
@@ -78,12 +90,25 @@ export function AutomationList({
78
90
 
79
91
  if (key.name === "p") {
80
92
  const newStatus = selected.status === "active" ? "paused" : "active";
81
- db.run(
82
- "UPDATE automations SET status = ?, updated_at = ? WHERE id = ?",
83
- [newStatus, Date.now(), selected.id],
84
- );
93
+ if (newStatus === "active" && selected.rrule) {
94
+ const rule = RRule.fromString(`RRULE:${selected.rrule}`);
95
+ const next = rule.after(new Date());
96
+ db.run(
97
+ "UPDATE automations SET status = ?, next_run_at = ?, updated_at = ? WHERE id = ?",
98
+ [newStatus, next ? next.getTime() : null, Date.now(), selected.id],
99
+ );
100
+ } else {
101
+ db.run(
102
+ "UPDATE automations SET status = ?, updated_at = ? WHERE id = ?",
103
+ [newStatus, Date.now(), selected.id],
104
+ );
105
+ }
85
106
  refresh();
86
- onNotify(newStatus === "paused" ? `Paused "${selected.name}"` : `Resumed "${selected.name}"`);
107
+ onNotify(
108
+ newStatus === "paused"
109
+ ? `Paused "${selected.name}"`
110
+ : `Resumed "${selected.name}"`,
111
+ );
87
112
  }
88
113
  });
89
114
 
@@ -126,17 +151,27 @@ export function AutomationList({
126
151
  const isRunning = a.running_count > 0;
127
152
  const isPaused = a.status === "paused";
128
153
  const s = isRunning
129
- ? { symbol: "⟳", color: "#5599ff" }
154
+ ? { symbol: spinnerFrame, color: "#5599ff" }
130
155
  : (STATUS_ICON[a.status] ?? STATUS_ICON.active);
131
156
  const sel = i === selectedIndex;
132
- const nameColor = sel ? "cyan" : isRunning ? "#ccc" : isPaused ? "#777" : "#ddd";
157
+ const nameColor = sel
158
+ ? "cyan"
159
+ : isRunning
160
+ ? "#ccc"
161
+ : isPaused
162
+ ? "#777"
163
+ : "#ddd";
133
164
  const suffixLen =
134
165
  (a.unread_count > 0 ? ` (${a.unread_count})`.length : 0) +
135
166
  (isRunning ? " running…".length : 0);
136
167
  const nameMax = MAX_NAME_LEN - suffixLen;
137
168
 
138
169
  return (
139
- <ListItem key={a.id} selected={sel} onClick={() => setSelectedIndex(i)}>
170
+ <ListItem
171
+ key={a.id}
172
+ selected={sel}
173
+ onClick={() => setSelectedIndex(i)}
174
+ >
140
175
  <text>
141
176
  <span fg={sel ? "cyan" : "#333"}>{sel ? "▸ " : " "}</span>
142
177
  <span fg={s.color}>{s.symbol} </span>
@@ -146,9 +181,7 @@ export function AutomationList({
146
181
  {a.unread_count > 0 ? (
147
182
  <span fg="cyan">{` (${a.unread_count})`}</span>
148
183
  ) : null}
149
- {isRunning ? (
150
- <span fg="#5599ff">{" "}running…</span>
151
- ) : null}
184
+ {isRunning ? <span fg="#5599ff"> running…</span> : null}
152
185
  </text>
153
186
  </ListItem>
154
187
  );
@@ -1,10 +1,10 @@
1
1
  import type { Run } from "@9to5/core";
2
+ import { useSpinner } from "../hooks/useSpinner.ts";
2
3
  import { Field } from "./Field.tsx";
3
4
  import { Section } from "./Section.tsx";
4
5
 
5
6
  const STATUS_STYLE: Record<string, { symbol: string; color: string }> = {
6
7
  pending: { symbol: "◦", color: "yellow" },
7
- running: { symbol: "⟳", color: "blue" },
8
8
  completed: { symbol: "✓", color: "green" },
9
9
  failed: { symbol: "✗", color: "red" },
10
10
  };
@@ -29,7 +29,11 @@ export function RunDetail({
29
29
  run,
30
30
  automationName,
31
31
  }: { run: Run; automationName?: string }) {
32
- const st = STATUS_STYLE[run.status] ?? STATUS_STYLE.pending;
32
+ const spinnerFrame = useSpinner(run.status === "running");
33
+ const st =
34
+ run.status === "running"
35
+ ? { symbol: spinnerFrame, color: "#5599ff" }
36
+ : (STATUS_STYLE[run.status] ?? STATUS_STYLE.pending);
33
37
 
34
38
  return (
35
39
  <scrollbox flexGrow={1}>
@@ -48,9 +52,7 @@ export function RunDetail({
48
52
  <Field
49
53
  label="Started"
50
54
  value={
51
- run.started_at
52
- ? new Date(run.started_at).toLocaleString()
53
- : null
55
+ run.started_at ? new Date(run.started_at).toLocaleString() : null
54
56
  }
55
57
  />
56
58
  <Field
@@ -64,14 +66,12 @@ export function RunDetail({
64
66
  </Section>
65
67
 
66
68
  {/* Stats */}
67
- {(run.cost_usd != null || run.duration_ms != null) ? (
69
+ {run.cost_usd != null || run.duration_ms != null ? (
68
70
  <Section title="Stats">
69
71
  <Field
70
72
  label="Cost"
71
73
  value={
72
- run.cost_usd != null
73
- ? `$${run.cost_usd.toFixed(4)}`
74
- : null
74
+ run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : null
75
75
  }
76
76
  />
77
77
  <Field label="Duration" value={formatDuration(run.duration_ms)} />
@@ -5,11 +5,11 @@ import { useCallback, useEffect } from "react";
5
5
  import { useDoubleTap } from "../hooks/useConfirm.ts";
6
6
  import { useDbQuery } from "../hooks/useDbQuery.ts";
7
7
  import { useListNav } from "../hooks/useListNav.ts";
8
+ import { useSpinner } from "../hooks/useSpinner.ts";
8
9
  import { ListItem } from "./ListItem.tsx";
9
10
 
10
11
  const STATUS_STYLE: Record<string, { symbol: string; color: string }> = {
11
12
  pending: { symbol: "◦", color: "yellow" },
12
- running: { symbol: "⟳", color: "#5599ff" },
13
13
  completed: { symbol: "✓", color: "green" },
14
14
  failed: { symbol: "✗", color: "red" },
15
15
  };
@@ -59,6 +59,8 @@ export function RunList({
59
59
  );
60
60
 
61
61
  const { selectedIndex, setSelectedIndex } = useListNav(rows.length, focused);
62
+ const hasRunning = rows.some((r) => r.status === "running");
63
+ const spinnerFrame = useSpinner(hasRunning);
62
64
 
63
65
  const selected = rows[selectedIndex] as RunRow | undefined;
64
66
 
@@ -94,14 +96,35 @@ export function RunList({
94
96
  lines.push(`Run: ${selected.id}`);
95
97
  lines.push(`Status: ${selected.status}`);
96
98
  if (selected.session_id) lines.push(`Session: ${selected.session_id}`);
97
- if (selected.started_at) lines.push(`Started: ${new Date(selected.started_at).toLocaleString()}`);
98
- if (selected.completed_at) lines.push(`Completed: ${new Date(selected.completed_at).toLocaleString()}`);
99
- if (selected.cost_usd != null) lines.push(`Cost: $${selected.cost_usd.toFixed(4)}`);
100
- if (selected.duration_ms != null) lines.push(`Duration: ${selected.duration_ms}ms`);
101
- if (selected.num_turns != null) lines.push(`Turns: ${selected.num_turns}`);
102
- if (selected.result) { lines.push(""); lines.push("--- Result ---"); lines.push(selected.result); }
103
- if (selected.output) { lines.push(""); lines.push("--- Output ---"); lines.push(selected.output); }
104
- if (selected.error) { lines.push(""); lines.push("--- Error ---"); lines.push(selected.error); }
99
+ if (selected.started_at)
100
+ lines.push(
101
+ `Started: ${new Date(selected.started_at).toLocaleString()}`,
102
+ );
103
+ if (selected.completed_at)
104
+ lines.push(
105
+ `Completed: ${new Date(selected.completed_at).toLocaleString()}`,
106
+ );
107
+ if (selected.cost_usd != null)
108
+ lines.push(`Cost: $${selected.cost_usd.toFixed(4)}`);
109
+ if (selected.duration_ms != null)
110
+ lines.push(`Duration: ${selected.duration_ms}ms`);
111
+ if (selected.num_turns != null)
112
+ lines.push(`Turns: ${selected.num_turns}`);
113
+ if (selected.result) {
114
+ lines.push("");
115
+ lines.push("--- Result ---");
116
+ lines.push(selected.result);
117
+ }
118
+ if (selected.output) {
119
+ lines.push("");
120
+ lines.push("--- Output ---");
121
+ lines.push(selected.output);
122
+ }
123
+ if (selected.error) {
124
+ lines.push("");
125
+ lines.push("--- Error ---");
126
+ lines.push(selected.error);
127
+ }
105
128
  const content = lines.join("\n");
106
129
  Bun.spawn(["pbcopy"], { stdin: new Blob([content]) });
107
130
  onNotify("Copied run details to clipboard");
@@ -131,7 +154,10 @@ export function RunList({
131
154
  return (
132
155
  <box flexDirection="column">
133
156
  {rows.map((r, i) => {
134
- const st = STATUS_STYLE[r.status] ?? STATUS_STYLE.pending;
157
+ const st =
158
+ r.status === "running"
159
+ ? { symbol: spinnerFrame, color: "#5599ff" }
160
+ : (STATUS_STYLE[r.status] ?? STATUS_STYLE.pending);
135
161
  const sel = i === selectedIndex;
136
162
  const unread = r.inbox_id != null && r.inbox_read_at == null;
137
163
  const nameColor = sel
@@ -143,7 +169,11 @@ export function RunList({
143
169
  : "#aaa";
144
170
 
145
171
  return (
146
- <ListItem key={r.id} selected={sel} onClick={() => setSelectedIndex(i)}>
172
+ <ListItem
173
+ key={r.id}
174
+ selected={sel}
175
+ onClick={() => setSelectedIndex(i)}
176
+ >
147
177
  <text>
148
178
  <span fg={sel ? "cyan" : "#333"}>{sel ? "▸ " : " "}</span>
149
179
  <span fg={st.color}>{st.symbol} </span>
@@ -11,7 +11,9 @@ export function Section({
11
11
  <strong>{title}</strong>
12
12
  </span>
13
13
  </text>
14
- <box flexDirection="column" paddingLeft={1}>{children}</box>
14
+ <box flexDirection="column" paddingLeft={1}>
15
+ {children}
16
+ </box>
15
17
  </box>
16
18
  );
17
19
  }
@@ -3,7 +3,13 @@ export function StatusBar({
3
3
  notification,
4
4
  }: { hints: { k: string; label: string }[]; notification?: string | null }) {
5
5
  return (
6
- <box height={1} flexDirection="row" paddingLeft={1} paddingRight={1} gap={2}>
6
+ <box
7
+ height={1}
8
+ flexDirection="row"
9
+ paddingLeft={1}
10
+ paddingRight={1}
11
+ gap={2}
12
+ >
7
13
  {hints.map((h) => (
8
14
  <text key={h.k}>
9
15
  <span fg="cyan">
@@ -3,10 +3,7 @@ import { useCallback, useRef } from "react";
3
3
 
4
4
  const DEBOUNCE_MS = 400;
5
5
 
6
- export function useDoubleTap(
7
- targetKey: string,
8
- onDoubleTap: () => void,
9
- ): void {
6
+ export function useDoubleTap(targetKey: string, onDoubleTap: () => void): void {
10
7
  const lastTapRef = useRef(0);
11
8
 
12
9
  useKeyboard(
@@ -1,7 +1,10 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
2
  import { useEffect, useState } from "react";
3
3
 
4
- export function useListNav(listLength: number, active = true): {
4
+ export function useListNav(
5
+ listLength: number,
6
+ active = true,
7
+ ): {
5
8
  selectedIndex: number;
6
9
  setSelectedIndex: (i: number) => void;
7
10
  scrollOffset: number;
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
+ const INTERVAL_MS = 80;
5
+
6
+ export function useSpinner(active: boolean): string {
7
+ const [index, setIndex] = useState(0);
8
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
9
+
10
+ useEffect(() => {
11
+ if (active) {
12
+ intervalRef.current = setInterval(() => {
13
+ setIndex((i) => (i + 1) % FRAMES.length);
14
+ }, INTERVAL_MS);
15
+ } else {
16
+ if (intervalRef.current) {
17
+ clearInterval(intervalRef.current);
18
+ intervalRef.current = null;
19
+ }
20
+ setIndex(0);
21
+ }
22
+
23
+ return () => {
24
+ if (intervalRef.current) {
25
+ clearInterval(intervalRef.current);
26
+ intervalRef.current = null;
27
+ }
28
+ };
29
+ }, [active]);
30
+
31
+ return active ? FRAMES[index] : "";
32
+ }