trismegistus 0.1.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/cli.js +569 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jessekaff
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# trismegistus
|
|
2
|
+
|
|
3
|
+
A local persistent daemon that runs Claude Code sessions from a task queue. Add tasks to a markdown file, start the daemon, and walk away — it works through your list overnight, retries failures with context, and lets you steer it from your phone.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D trismegistus
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install globally:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g trismegistus
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Prerequisites
|
|
18
|
+
|
|
19
|
+
- Node.js >= 18
|
|
20
|
+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`npm install -g @anthropic-ai/claude-code`)
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Initialize in your project
|
|
26
|
+
npx tmg init
|
|
27
|
+
|
|
28
|
+
# Add tasks from the CLI
|
|
29
|
+
npx tmg add "Migrate user model to TypeScript"
|
|
30
|
+
npx tmg add "Write tests for the payment module"
|
|
31
|
+
|
|
32
|
+
# Start the daemon
|
|
33
|
+
npx tmg start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The daemon picks up tasks one at a time, runs each in a full Claude Code session with `--dangerously-skip-permissions`, commits the work, and moves on to the next.
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
| Command | Description |
|
|
41
|
+
|---------|-------------|
|
|
42
|
+
| `tmg init` | Create `.trismegistus/` folder with config, tasks, and notes files |
|
|
43
|
+
| `tmg add "task"` | Add a task to the queue |
|
|
44
|
+
| `tmg start` | Start the daemon — runs tasks continuously until the queue is empty |
|
|
45
|
+
| `tmg status` | Show counts of pending, in-progress, done, retrying, and gave-up tasks |
|
|
46
|
+
| `tmg remote` | Open a VS Code tunnel with QR code for phone access |
|
|
47
|
+
| `tmg reset` | Reset all gave-up `[!!!]` tasks back to pending `[ ]` |
|
|
48
|
+
| `tmg -v` | Show version |
|
|
49
|
+
|
|
50
|
+
## Task File Format
|
|
51
|
+
|
|
52
|
+
Tasks live in `.trismegistus/tasks.md` — plain markdown with checkbox syntax:
|
|
53
|
+
|
|
54
|
+
```markdown
|
|
55
|
+
- [ ] Add authentication to the API
|
|
56
|
+
- [ ] Write tests for the payment module
|
|
57
|
+
- [ ] Refactor database queries to use connection pooling
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The daemon updates statuses as it works:
|
|
61
|
+
|
|
62
|
+
| Marker | Meaning |
|
|
63
|
+
|--------|---------|
|
|
64
|
+
| `[ ]` | Pending — waiting to run |
|
|
65
|
+
| `[~]` | In progress — currently running |
|
|
66
|
+
| `[x]` | Done |
|
|
67
|
+
| `[!]` | Failed once — will retry |
|
|
68
|
+
| `[!!]` | Failed twice — will retry |
|
|
69
|
+
| `[!!!]` | Gave up — exceeded max retries |
|
|
70
|
+
|
|
71
|
+
## Workflows & Use Cases
|
|
72
|
+
|
|
73
|
+
### Run tasks overnight
|
|
74
|
+
|
|
75
|
+
The core use case. Queue up a list of tasks before bed, start the daemon, and wake up to completed work.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Add your tasks
|
|
79
|
+
cat >> .trismegistus/tasks.md << 'EOF'
|
|
80
|
+
- [ ] Migrate user model to TypeScript
|
|
81
|
+
- [ ] Add input validation to all API routes
|
|
82
|
+
- [ ] Write integration tests for the checkout flow
|
|
83
|
+
EOF
|
|
84
|
+
|
|
85
|
+
# Start and let it run
|
|
86
|
+
tmg start
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each task gets a full Claude Code session with autonomous permissions — it can read files, write code, run commands, and commit.
|
|
90
|
+
|
|
91
|
+
### Leave notes for Claude
|
|
92
|
+
|
|
93
|
+
While the daemon is running, drop notes in `.trismegistus/notes.md`. The daemon reads and clears them before starting the next task — so you can steer it locally or from any device that can edit the file.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
echo "Use Prisma instead of raw SQL for the database tasks" >> .trismegistus/notes.md
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Notes are passed to Claude with priority, so you can redirect approach, add context, or give instructions without stopping the daemon. You can even ask Claude to add new tasks — it will edit `tasks.md` itself, and the daemon picks them up next cycle.
|
|
100
|
+
|
|
101
|
+
### Add tasks while it's running
|
|
102
|
+
|
|
103
|
+
The task file is just markdown. Add new lines while the daemon is running and it will pick them up when the current task finishes.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
echo "- [ ] Fix the bug in the login form" >> .trismegistus/tasks.md
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Monitor from your phone
|
|
110
|
+
|
|
111
|
+
`tmg remote` creates a secure VS Code tunnel through Microsoft's Azure relay and prints a QR code. Scan it on your phone to get a full VS Code UI — including terminal access — right in the browser. No port forwarding or same-network requirement.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
tmg remote
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Prerequisites: VS Code `code` CLI installed, GitHub account (one-time device auth on first use).
|
|
118
|
+
|
|
119
|
+
You can set a custom tunnel name:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
tmg remote --name my-machine
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Check progress remotely
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
tmg status
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
TMG Status
|
|
133
|
+
─────────────
|
|
134
|
+
Pending: 3
|
|
135
|
+
In Progress: 1
|
|
136
|
+
Done: 5
|
|
137
|
+
Retrying (!): 0
|
|
138
|
+
Retrying (!!): 0
|
|
139
|
+
Gave up (!!!): 1
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Retry failed tasks
|
|
143
|
+
|
|
144
|
+
When a task fails 3 times, it's marked `[!!!]` and skipped. After fixing the underlying issue, reset all gave-up tasks:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
tmg reset
|
|
148
|
+
tmg start
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Automatic handoff between retries
|
|
152
|
+
|
|
153
|
+
When a task fails, Claude writes a handoff summary to `.trismegistus/handoff`. The next attempt receives this context so it can pick up where the previous session left off rather than starting from scratch.
|
|
154
|
+
|
|
155
|
+
## Configuration
|
|
156
|
+
|
|
157
|
+
Edit `.trismegistus/config` to tune the daemon:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
MAX_RETRIES=3 # Attempts per task before giving up
|
|
161
|
+
TIMEOUT_MINUTES=30 # Max runtime per task
|
|
162
|
+
IDLE_POLL_SECONDS=10 # Poll interval when no tasks available
|
|
163
|
+
TASK_DELAY_SECONDS=5 # Pause between tasks
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## How It Works
|
|
167
|
+
|
|
168
|
+
1. `tmg start` runs preflight checks (directory exists, Claude CLI installed)
|
|
169
|
+
2. The daemon polls `tasks.md` for the next pending or retryable task
|
|
170
|
+
3. It reads any human notes from `notes.md` (then clears the file)
|
|
171
|
+
4. It reads any handoff context from a previous failed attempt
|
|
172
|
+
5. It spawns `claude --dangerously-skip-permissions` with a constructed prompt
|
|
173
|
+
6. On success: marks the task `[x]` and moves on
|
|
174
|
+
7. On failure: escalates the status (`[ ]` -> `[!]` -> `[!!]` -> `[!!!]`), saves handoff context, and retries
|
|
175
|
+
8. When the queue is empty, it idles and watches for new tasks
|
|
176
|
+
|
|
177
|
+
## Project Structure
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
.trismegistus/
|
|
181
|
+
config # Daemon configuration
|
|
182
|
+
tasks.md # Your task queue
|
|
183
|
+
notes.md # Notes for Claude (cleared after each read)
|
|
184
|
+
handoff # Context passed between retry attempts (auto-managed)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createRequire as createRequire2 } from "module";
|
|
6
|
+
import { hostname } from "os";
|
|
7
|
+
import qrcode from "qrcode-terminal";
|
|
8
|
+
|
|
9
|
+
// src/init.ts
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
// src/types.ts
|
|
14
|
+
var DEFAULT_CONFIG = {
|
|
15
|
+
maxRetries: 3,
|
|
16
|
+
timeoutMinutes: 30,
|
|
17
|
+
idlePollSeconds: 10,
|
|
18
|
+
taskDelaySeconds: 5
|
|
19
|
+
};
|
|
20
|
+
var DIR_NAME = ".trismegistus";
|
|
21
|
+
var CONFIG_FILE = "config";
|
|
22
|
+
var TASKS_FILE = "tasks.md";
|
|
23
|
+
var NOTES_FILE = "notes.md";
|
|
24
|
+
var HANDOFF_FILE = "handoff";
|
|
25
|
+
var TASKS_TEMPLATE = `# Tasks \u2014 one per line
|
|
26
|
+
# - [ ] = pending - [x] = done
|
|
27
|
+
# - [!] = failed once (retrying) - [!!] = twice - [!!!] = gave up
|
|
28
|
+
|
|
29
|
+
- [ ] Example: replace with your first real task
|
|
30
|
+
`;
|
|
31
|
+
var NOTES_TEMPLATE = `# Notes for Claude \u2014 write here, cleared after each read
|
|
32
|
+
`;
|
|
33
|
+
var CONFIG_TEMPLATE = `# Trismegistus Configuration
|
|
34
|
+
MAX_RETRIES=3
|
|
35
|
+
TIMEOUT_MINUTES=30
|
|
36
|
+
IDLE_POLL_SECONDS=10
|
|
37
|
+
TASK_DELAY_SECONDS=5
|
|
38
|
+
`;
|
|
39
|
+
var STATUS_PRIORITY = [" ", "!", "!!"];
|
|
40
|
+
|
|
41
|
+
// src/init.ts
|
|
42
|
+
function initProject(projectDir) {
|
|
43
|
+
const tmgDir = join(projectDir, DIR_NAME);
|
|
44
|
+
const result = { created: [], skipped: [] };
|
|
45
|
+
if (!existsSync(tmgDir)) {
|
|
46
|
+
mkdirSync(tmgDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
const files = [
|
|
49
|
+
{ name: CONFIG_FILE, content: CONFIG_TEMPLATE },
|
|
50
|
+
{ name: TASKS_FILE, content: TASKS_TEMPLATE },
|
|
51
|
+
{ name: NOTES_FILE, content: NOTES_TEMPLATE }
|
|
52
|
+
];
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const path = join(tmgDir, file.name);
|
|
55
|
+
if (existsSync(path)) {
|
|
56
|
+
result.skipped.push(file.name);
|
|
57
|
+
} else {
|
|
58
|
+
writeFileSync(path, file.content);
|
|
59
|
+
result.created.push(file.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/tasks.ts
|
|
66
|
+
import { readFileSync, writeFileSync as writeFileSync2, existsSync as existsSync2, unlinkSync } from "fs";
|
|
67
|
+
import { join as join2 } from "path";
|
|
68
|
+
var TASK_REGEX = /^- \[([^\]]*)\] (.+)$/;
|
|
69
|
+
function tasksPath(projectDir) {
|
|
70
|
+
return join2(projectDir, DIR_NAME, TASKS_FILE);
|
|
71
|
+
}
|
|
72
|
+
function notesPath(projectDir) {
|
|
73
|
+
return join2(projectDir, DIR_NAME, NOTES_FILE);
|
|
74
|
+
}
|
|
75
|
+
function handoffPath(projectDir) {
|
|
76
|
+
return join2(projectDir, DIR_NAME, HANDOFF_FILE);
|
|
77
|
+
}
|
|
78
|
+
function parseTasks(content) {
|
|
79
|
+
const tasks = [];
|
|
80
|
+
const lines = content.split("\n");
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const match = lines[i].match(TASK_REGEX);
|
|
83
|
+
if (match) {
|
|
84
|
+
const status = match[1];
|
|
85
|
+
tasks.push({ status, text: match[2], line: i + 1 });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return tasks;
|
|
89
|
+
}
|
|
90
|
+
function readTasks(projectDir) {
|
|
91
|
+
const path = tasksPath(projectDir);
|
|
92
|
+
if (!existsSync2(path)) return [];
|
|
93
|
+
return parseTasks(readFileSync(path, "utf-8"));
|
|
94
|
+
}
|
|
95
|
+
function getNextTask(projectDir) {
|
|
96
|
+
const tasks = readTasks(projectDir);
|
|
97
|
+
for (const status of STATUS_PRIORITY) {
|
|
98
|
+
const task = tasks.find((t) => t.status === status);
|
|
99
|
+
if (task) return task;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function setTaskStatus(projectDir, taskText, newStatus) {
|
|
104
|
+
const path = tasksPath(projectDir);
|
|
105
|
+
const content = readFileSync(path, "utf-8");
|
|
106
|
+
const lines = content.split("\n");
|
|
107
|
+
let found = false;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const match = lines[i].match(TASK_REGEX);
|
|
110
|
+
if (match && match[2] === taskText) {
|
|
111
|
+
lines[i] = `- [${newStatus}] ${taskText}`;
|
|
112
|
+
found = true;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (found) {
|
|
117
|
+
writeFileSync2(path, lines.join("\n"));
|
|
118
|
+
}
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
function getTaskCounts(projectDir) {
|
|
122
|
+
const tasks = readTasks(projectDir);
|
|
123
|
+
return {
|
|
124
|
+
pending: tasks.filter((t) => t.status === " ").length,
|
|
125
|
+
inProgress: tasks.filter((t) => t.status === "~").length,
|
|
126
|
+
done: tasks.filter((t) => t.status === "x").length,
|
|
127
|
+
failed1: tasks.filter((t) => t.status === "!").length,
|
|
128
|
+
failed2: tasks.filter((t) => t.status === "!!").length,
|
|
129
|
+
gaveUp: tasks.filter((t) => t.status === "!!!").length
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function resetGaveUpTasks(projectDir) {
|
|
133
|
+
const path = tasksPath(projectDir);
|
|
134
|
+
if (!existsSync2(path)) return 0;
|
|
135
|
+
const content = readFileSync(path, "utf-8");
|
|
136
|
+
const lines = content.split("\n");
|
|
137
|
+
let count = 0;
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const match = lines[i].match(TASK_REGEX);
|
|
140
|
+
if (match && match[1] === "!!!") {
|
|
141
|
+
lines[i] = `- [ ] ${match[2]}`;
|
|
142
|
+
count++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (count > 0) writeFileSync2(path, lines.join("\n"));
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
function readAndClearNotes(projectDir) {
|
|
149
|
+
const path = notesPath(projectDir);
|
|
150
|
+
if (!existsSync2(path)) return "";
|
|
151
|
+
const content = readFileSync(path, "utf-8");
|
|
152
|
+
const notes = content.split("\n").filter((l) => !l.startsWith("#") && l.trim() !== "").slice(0, 50).join("\n");
|
|
153
|
+
writeFileSync2(
|
|
154
|
+
path,
|
|
155
|
+
"# Notes for Claude \u2014 write here, cleared after each read\n"
|
|
156
|
+
);
|
|
157
|
+
return notes;
|
|
158
|
+
}
|
|
159
|
+
function readHandoff(projectDir) {
|
|
160
|
+
const path = handoffPath(projectDir);
|
|
161
|
+
if (!existsSync2(path)) return "";
|
|
162
|
+
return readFileSync(path, "utf-8");
|
|
163
|
+
}
|
|
164
|
+
function writeHandoff(projectDir, content) {
|
|
165
|
+
writeFileSync2(handoffPath(projectDir), content);
|
|
166
|
+
}
|
|
167
|
+
function deleteHandoff(projectDir) {
|
|
168
|
+
const path = handoffPath(projectDir);
|
|
169
|
+
if (existsSync2(path)) {
|
|
170
|
+
unlinkSync(path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function getAttemptFromStatus(status) {
|
|
174
|
+
switch (status) {
|
|
175
|
+
case "!":
|
|
176
|
+
return 2;
|
|
177
|
+
case "!!":
|
|
178
|
+
return 3;
|
|
179
|
+
default:
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function addTask(projectDir, text) {
|
|
184
|
+
const trimmed = text.trim();
|
|
185
|
+
if (!trimmed) {
|
|
186
|
+
throw new Error("Task text cannot be empty.");
|
|
187
|
+
}
|
|
188
|
+
const path = tasksPath(projectDir);
|
|
189
|
+
if (!existsSync2(path)) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"No tasks file found. Run `tmg init` first."
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const content = readFileSync(path, "utf-8");
|
|
195
|
+
const line = `- [ ] ${trimmed}
|
|
196
|
+
`;
|
|
197
|
+
const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
198
|
+
writeFileSync2(path, content + separator + line);
|
|
199
|
+
}
|
|
200
|
+
function getFailureStatus(attempt) {
|
|
201
|
+
if (attempt === 1) return "!";
|
|
202
|
+
if (attempt === 2) return "!!";
|
|
203
|
+
return "!!!";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/daemon.ts
|
|
207
|
+
import { existsSync as existsSync4 } from "fs";
|
|
208
|
+
import { join as join4 } from "path";
|
|
209
|
+
import { execFileSync } from "child_process";
|
|
210
|
+
|
|
211
|
+
// src/config.ts
|
|
212
|
+
import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
|
|
213
|
+
import { join as join3 } from "path";
|
|
214
|
+
var KEY_MAP = {
|
|
215
|
+
MAX_RETRIES: "maxRetries",
|
|
216
|
+
TIMEOUT_MINUTES: "timeoutMinutes",
|
|
217
|
+
IDLE_POLL_SECONDS: "idlePollSeconds",
|
|
218
|
+
TASK_DELAY_SECONDS: "taskDelaySeconds"
|
|
219
|
+
};
|
|
220
|
+
function parseConfigFile(content) {
|
|
221
|
+
const result = {};
|
|
222
|
+
for (const line of content.split("\n")) {
|
|
223
|
+
const trimmed = line.trim();
|
|
224
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
225
|
+
const eqIndex = trimmed.indexOf("=");
|
|
226
|
+
if (eqIndex === -1) continue;
|
|
227
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
228
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
229
|
+
const configKey = KEY_MAP[key];
|
|
230
|
+
if (!configKey) continue;
|
|
231
|
+
const num = parseInt(value, 10);
|
|
232
|
+
if (!isNaN(num) && num >= 0) {
|
|
233
|
+
result[configKey] = num;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
function loadConfig(projectDir) {
|
|
239
|
+
const configPath = join3(projectDir, DIR_NAME, CONFIG_FILE);
|
|
240
|
+
if (!existsSync3(configPath)) {
|
|
241
|
+
return { ...DEFAULT_CONFIG };
|
|
242
|
+
}
|
|
243
|
+
const content = readFileSync2(configPath, "utf-8");
|
|
244
|
+
const overrides = parseConfigFile(content);
|
|
245
|
+
return { ...DEFAULT_CONFIG, ...overrides };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/runner.ts
|
|
249
|
+
import { spawn } from "child_process";
|
|
250
|
+
function runClaude(prompt, timeoutMs, projectDir, spawnFn = spawn) {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const ac = new AbortController();
|
|
253
|
+
let timedOut = false;
|
|
254
|
+
const timer = setTimeout(() => {
|
|
255
|
+
timedOut = true;
|
|
256
|
+
ac.abort();
|
|
257
|
+
}, timeoutMs);
|
|
258
|
+
const child = spawnFn(
|
|
259
|
+
"claude",
|
|
260
|
+
["--dangerously-skip-permissions", "-p", prompt],
|
|
261
|
+
{
|
|
262
|
+
cwd: projectDir,
|
|
263
|
+
stdio: "inherit",
|
|
264
|
+
signal: ac.signal
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
child.on("close", (code) => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
resolve({
|
|
270
|
+
success: !timedOut && code === 0,
|
|
271
|
+
exitCode: timedOut ? 124 : code ?? 1,
|
|
272
|
+
timedOut
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
child.on("error", (err) => {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
resolve({
|
|
278
|
+
success: false,
|
|
279
|
+
exitCode: 1,
|
|
280
|
+
timedOut: timedOut || err.name === "AbortError"
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/daemon.ts
|
|
287
|
+
import { createRequire } from "module";
|
|
288
|
+
var require2 = createRequire(import.meta.url);
|
|
289
|
+
var VERSION = require2("../package.json").version;
|
|
290
|
+
function preflight(projectDir) {
|
|
291
|
+
const errors = [];
|
|
292
|
+
const warnings = [];
|
|
293
|
+
const tmgDir = join4(projectDir, DIR_NAME);
|
|
294
|
+
if (!existsSync4(tmgDir)) {
|
|
295
|
+
errors.push(`.trismegistus/ not found. Run: tmg init`);
|
|
296
|
+
return { ok: false, errors, warnings };
|
|
297
|
+
}
|
|
298
|
+
if (!existsSync4(join4(tmgDir, TASKS_FILE))) {
|
|
299
|
+
errors.push(`tasks.md not found in .trismegistus/. Run: tmg init`);
|
|
300
|
+
return { ok: false, errors, warnings };
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
execFileSync("which", ["claude"], { stdio: "ignore" });
|
|
304
|
+
} catch {
|
|
305
|
+
errors.push(
|
|
306
|
+
"Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code"
|
|
307
|
+
);
|
|
308
|
+
return { ok: false, errors, warnings };
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
execFileSync("claude", ["--version"], { stdio: "ignore" });
|
|
312
|
+
} catch {
|
|
313
|
+
warnings.push(
|
|
314
|
+
"Claude CLI found but 'claude --version' failed. Authentication may be needed."
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
318
|
+
}
|
|
319
|
+
function buildPrompt(taskText, attempt, maxRetries, notes, handoff) {
|
|
320
|
+
let prompt = `You are running autonomously overnight (attempt ${attempt}/${maxRetries}). Complete this task fully, commit your work, don't ask questions.
|
|
321
|
+
|
|
322
|
+
If you are running low on time, write a summary of what you did and what remains to .trismegistus/handoff so the next session can continue.`;
|
|
323
|
+
if (notes) {
|
|
324
|
+
prompt += `
|
|
325
|
+
|
|
326
|
+
NOTES FROM HUMAN (priority):
|
|
327
|
+
${notes}`;
|
|
328
|
+
}
|
|
329
|
+
if (handoff) {
|
|
330
|
+
prompt += `
|
|
331
|
+
|
|
332
|
+
CONTEXT FROM PREVIOUS ATTEMPT (pick up where this left off):
|
|
333
|
+
${handoff}`;
|
|
334
|
+
}
|
|
335
|
+
prompt += `
|
|
336
|
+
|
|
337
|
+
Task: ${taskText}`;
|
|
338
|
+
return prompt;
|
|
339
|
+
}
|
|
340
|
+
function sleep(ms) {
|
|
341
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
342
|
+
}
|
|
343
|
+
async function runDaemon(opts) {
|
|
344
|
+
const { projectDir, spawnFn, maxIterations, onLog } = opts;
|
|
345
|
+
const log = onLog ?? ((msg) => console.log(msg));
|
|
346
|
+
const config = loadConfig(projectDir);
|
|
347
|
+
log(` TMG v${VERSION} \u2014 ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
|
|
348
|
+
log(` Project: ${projectDir}`);
|
|
349
|
+
log(` Timeout: ${config.timeoutMinutes}m | Max retries: ${config.maxRetries}`);
|
|
350
|
+
log("");
|
|
351
|
+
let iterations = 0;
|
|
352
|
+
while (true) {
|
|
353
|
+
if (maxIterations !== void 0 && iterations >= maxIterations) break;
|
|
354
|
+
iterations++;
|
|
355
|
+
const task = getNextTask(projectDir);
|
|
356
|
+
if (!task) {
|
|
357
|
+
log(`[${time()}] No tasks. Watching...`);
|
|
358
|
+
await sleep(config.idlePollSeconds * 1e3);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const attempt = getAttemptFromStatus(task.status);
|
|
362
|
+
const notes = readAndClearNotes(projectDir);
|
|
363
|
+
const handoff = readHandoff(projectDir);
|
|
364
|
+
log("");
|
|
365
|
+
log("\u2501".repeat(47));
|
|
366
|
+
log(`\u25B6 [${time()}] ${task.text} (attempt ${attempt}/${config.maxRetries})`);
|
|
367
|
+
log("\u2501".repeat(47));
|
|
368
|
+
setTaskStatus(projectDir, task.text, "~");
|
|
369
|
+
const prompt = buildPrompt(
|
|
370
|
+
task.text,
|
|
371
|
+
attempt,
|
|
372
|
+
config.maxRetries,
|
|
373
|
+
notes,
|
|
374
|
+
handoff
|
|
375
|
+
);
|
|
376
|
+
const result = await runClaude(
|
|
377
|
+
prompt,
|
|
378
|
+
config.timeoutMinutes * 60 * 1e3,
|
|
379
|
+
projectDir,
|
|
380
|
+
spawnFn
|
|
381
|
+
);
|
|
382
|
+
if (result.success) {
|
|
383
|
+
setTaskStatus(projectDir, task.text, "x");
|
|
384
|
+
deleteHandoff(projectDir);
|
|
385
|
+
log(`[${time()}] Done: ${task.text}`);
|
|
386
|
+
} else {
|
|
387
|
+
if (result.timedOut) {
|
|
388
|
+
log(`[${time()}] Timed out`);
|
|
389
|
+
} else {
|
|
390
|
+
log(`[${time()}] Exited (${result.exitCode})`);
|
|
391
|
+
}
|
|
392
|
+
if (!existsSync4(join4(projectDir, DIR_NAME, "handoff"))) {
|
|
393
|
+
const reason = result.timedOut ? "timed out" : "failed";
|
|
394
|
+
writeHandoff(
|
|
395
|
+
projectDir,
|
|
396
|
+
`Previous attempt ${reason}. Check git log and codebase to see what was done. Continue from there.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (attempt >= config.maxRetries) {
|
|
400
|
+
setTaskStatus(projectDir, task.text, "!!!");
|
|
401
|
+
deleteHandoff(projectDir);
|
|
402
|
+
log(`Gave up after ${config.maxRetries} attempts: ${task.text}`);
|
|
403
|
+
} else {
|
|
404
|
+
const failStatus = getFailureStatus(attempt);
|
|
405
|
+
setTaskStatus(projectDir, task.text, failStatus);
|
|
406
|
+
log(` \u2192 Will retry with handoff context...`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
await sleep(config.taskDelaySeconds * 1e3);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function time() {
|
|
413
|
+
const now = /* @__PURE__ */ new Date();
|
|
414
|
+
return `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/tunnel.ts
|
|
418
|
+
import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
|
|
419
|
+
function checkCodeCli() {
|
|
420
|
+
try {
|
|
421
|
+
execFileSync2("which", ["code"], { stdio: "ignore" });
|
|
422
|
+
} catch {
|
|
423
|
+
throw new Error(
|
|
424
|
+
"VS Code CLI (code) not found. Install VS Code or download the standalone CLI: https://code.visualstudio.com/docs/editor/command-line"
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function startTunnel(name) {
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
const child = spawn2(
|
|
431
|
+
"code",
|
|
432
|
+
["tunnel", "--name", name, "--accept-server-license-terms"],
|
|
433
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
434
|
+
);
|
|
435
|
+
let stderr = "";
|
|
436
|
+
let settled = false;
|
|
437
|
+
const TIMEOUT_MS = 6e4;
|
|
438
|
+
function settle(fn) {
|
|
439
|
+
if (!settled) {
|
|
440
|
+
settled = true;
|
|
441
|
+
fn();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
child.kill();
|
|
446
|
+
const msg = stderr ? `Timed out waiting for tunnel URL. stderr:
|
|
447
|
+
${stderr}` : "Timed out waiting for tunnel URL. You may need to authenticate \u2014 run `code tunnel` manually first.";
|
|
448
|
+
settle(() => reject(new Error(msg)));
|
|
449
|
+
}, TIMEOUT_MS);
|
|
450
|
+
child.stderr?.on("data", (chunk) => {
|
|
451
|
+
stderr += chunk.toString();
|
|
452
|
+
});
|
|
453
|
+
child.stdout?.on("data", (chunk) => {
|
|
454
|
+
const text = chunk.toString();
|
|
455
|
+
const match = text.match(/(https:\/\/vscode\.dev\/tunnel\/[^\s]+)/);
|
|
456
|
+
if (match) {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
settle(() => resolve({ url: match[1], process: child }));
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
child.on("error", (err) => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
settle(() => reject(err));
|
|
464
|
+
});
|
|
465
|
+
child.on("close", (code) => {
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
settle(() => reject(new Error(`code tunnel exited with code ${code}. stderr:
|
|
468
|
+
${stderr}`)));
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/cli.ts
|
|
474
|
+
var program = new Command();
|
|
475
|
+
program.name("tmg").description("Trismegistus \u2014 Task Manager for Claude Code").version(
|
|
476
|
+
(() => {
|
|
477
|
+
const req = createRequire2(import.meta.url);
|
|
478
|
+
return req("../package.json").version;
|
|
479
|
+
})(),
|
|
480
|
+
"-v, --version"
|
|
481
|
+
);
|
|
482
|
+
program.command("init").description("Create .trismegistus/ folder in current directory").action(() => {
|
|
483
|
+
const result = initProject(process.cwd());
|
|
484
|
+
for (const name of result.created) {
|
|
485
|
+
console.log(` Created ${name}`);
|
|
486
|
+
}
|
|
487
|
+
for (const name of result.skipped) {
|
|
488
|
+
console.log(` Skipped ${name} (already exists)`);
|
|
489
|
+
}
|
|
490
|
+
console.log("");
|
|
491
|
+
console.log(" Add your tasks to .trismegistus/tasks.md, then run: tmg start");
|
|
492
|
+
console.log("");
|
|
493
|
+
});
|
|
494
|
+
program.command("status").description("Show task counts").action(() => {
|
|
495
|
+
const counts = getTaskCounts(process.cwd());
|
|
496
|
+
console.log("");
|
|
497
|
+
console.log(" TMG Status");
|
|
498
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
499
|
+
console.log(` Pending: ${counts.pending}`);
|
|
500
|
+
console.log(` In Progress: ${counts.inProgress}`);
|
|
501
|
+
console.log(` Done: ${counts.done}`);
|
|
502
|
+
console.log(` Retrying (!): ${counts.failed1}`);
|
|
503
|
+
console.log(` Retrying (!!): ${counts.failed2}`);
|
|
504
|
+
console.log(` Gave up (!!!): ${counts.gaveUp}`);
|
|
505
|
+
console.log("");
|
|
506
|
+
});
|
|
507
|
+
program.command("start").description("Start the daemon \u2014 continuously runs tasks from the queue").action(async () => {
|
|
508
|
+
const check = preflight(process.cwd());
|
|
509
|
+
for (const err of check.errors) {
|
|
510
|
+
console.error(` Error: ${err}`);
|
|
511
|
+
}
|
|
512
|
+
for (const warn of check.warnings) {
|
|
513
|
+
console.warn(` Warning: ${warn}`);
|
|
514
|
+
}
|
|
515
|
+
if (!check.ok) {
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
console.log("");
|
|
519
|
+
await runDaemon({ projectDir: process.cwd() });
|
|
520
|
+
});
|
|
521
|
+
program.command("add").description("Add a task to the queue").argument("<text>", "Task description").action((text) => {
|
|
522
|
+
try {
|
|
523
|
+
addTask(process.cwd(), text);
|
|
524
|
+
console.log(`Added: ${text}`);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
program.command("reset").description("Reset all gave-up [!!!] tasks back to pending [ ]").action(() => {
|
|
531
|
+
const count = resetGaveUpTasks(process.cwd());
|
|
532
|
+
if (count === 0) {
|
|
533
|
+
console.log(" No gave-up tasks to reset.");
|
|
534
|
+
} else {
|
|
535
|
+
console.log(` Reset ${count} task(s) back to pending.`);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
program.command("remote").description("Open a VS Code tunnel for phone access (QR code)").option("--name <name>", "Tunnel name").action(async (opts) => {
|
|
539
|
+
const tunnelName = opts.name ?? hostname();
|
|
540
|
+
try {
|
|
541
|
+
checkCodeCli();
|
|
542
|
+
} catch (e) {
|
|
543
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
console.log("Starting VS Code tunnel...");
|
|
547
|
+
try {
|
|
548
|
+
const { url, process: tunnelProc } = await startTunnel(tunnelName);
|
|
549
|
+
console.log("");
|
|
550
|
+
console.log(` URL: ${url}`);
|
|
551
|
+
console.log("");
|
|
552
|
+
qrcode.generate(url, { small: true }, (code) => {
|
|
553
|
+
console.log(code);
|
|
554
|
+
});
|
|
555
|
+
console.log(" Scan the QR code or open the URL on your phone");
|
|
556
|
+
console.log(" Press Ctrl+C to stop the tunnel");
|
|
557
|
+
console.log("");
|
|
558
|
+
const cleanup = () => {
|
|
559
|
+
tunnelProc.kill();
|
|
560
|
+
process.exit(0);
|
|
561
|
+
};
|
|
562
|
+
process.on("SIGINT", cleanup);
|
|
563
|
+
process.on("SIGTERM", cleanup);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trismegistus",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A local persistent daemon that runs AI sessions from a task queue, with mobile support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tmg": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"dev": "tsx src/cli.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"task-manager",
|
|
21
|
+
"daemon",
|
|
22
|
+
"automation",
|
|
23
|
+
"ai"
|
|
24
|
+
],
|
|
25
|
+
"author": "jessekaff",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/jessekaff/trismegistus.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/jessekaff/trismegistus#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/jessekaff/trismegistus/issues"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"commander": "^13.0.0",
|
|
43
|
+
"qrcode-terminal": "^0.12.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
48
|
+
"tsup": "^8.0.0",
|
|
49
|
+
"tsx": "^4.0.0",
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|