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 +2 -6
- package/packages/cli/src/commands/edit.ts +24 -11
- package/packages/cli/src/commands/onboard.ts +73 -0
- package/packages/cli/src/index.ts +2 -0
- package/packages/core/src/config.ts +13 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/tui/src/app.tsx +110 -94
- package/packages/tui/src/components/AutomationDetail.tsx +17 -16
- package/packages/tui/src/components/AutomationList.tsx +46 -13
- package/packages/tui/src/components/RunDetail.tsx +9 -9
- package/packages/tui/src/components/RunList.tsx +41 -11
- package/packages/tui/src/components/Section.tsx +3 -1
- package/packages/tui/src/components/StatusBar.tsx +7 -1
- package/packages/tui/src/hooks/useConfirm.ts +1 -4
- package/packages/tui/src/hooks/useListNav.ts +4 -1
- package/packages/tui/src/hooks/useSpinner.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "9to5",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"--
|
|
17
|
-
"Update
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/packages/tui/src/app.tsx
CHANGED
|
@@ -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] =
|
|
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(
|
|
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
|
-
|
|
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"
|
|
89
|
+
<span fg="cyan">
|
|
90
|
+
<strong>Automations</strong>
|
|
91
|
+
</span>
|
|
85
92
|
) : (
|
|
86
93
|
<>
|
|
87
94
|
<span fg="cyan">{"← "}</span>
|
|
88
|
-
<span fg="cyan"
|
|
95
|
+
<span fg="cyan">
|
|
96
|
+
<strong>{selectedAutomation?.name ?? ""}</strong>
|
|
97
|
+
</span>
|
|
89
98
|
</>
|
|
90
99
|
);
|
|
91
100
|
|
|
92
101
|
return (
|
|
93
|
-
<box
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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="#
|
|
113
|
+
<span fg="#c084fc">
|
|
114
|
+
<strong>{"◆ 9to5"}</strong>
|
|
115
|
+
</span>
|
|
117
116
|
</text>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
{/*
|
|
152
|
-
<box
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
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 =
|
|
116
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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:
|
|
154
|
+
? { symbol: spinnerFrame, color: "#5599ff" }
|
|
130
155
|
: (STATUS_ICON[a.status] ?? STATUS_ICON.active);
|
|
131
156
|
const sel = i === selectedIndex;
|
|
132
|
-
const nameColor = sel
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (selected.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 =
|
|
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
|
|
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>
|
|
@@ -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
|
|
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(
|
|
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
|
+
}
|