termdeck-cli 1.0.2 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -174
- package/package.json +9 -7
- package/sample-config.json +176 -0
- package/src/agentManager.js +217 -0
- package/src/config.js +636 -427
- package/src/dashboard.js +502 -198
- package/src/devServer.js +51 -2
- package/src/index.js +103 -9
- package/src/processMonitor.js +168 -0
- package/src/projectManager.js +159 -0
- package/src/updater.js +172 -0
- package/src/util.js +23 -0
- package/bin/termdeck.js +0 -22
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agentManager - launch and track the built-in coding agents.
|
|
5
|
+
*
|
|
6
|
+
* Five interactive CLI agents are supported out of the box:
|
|
7
|
+
*
|
|
8
|
+
* claude · codex · opencode · freebuff · kilocode
|
|
9
|
+
*
|
|
10
|
+
* Agents are *always* interactive: they run in a brand new detached terminal
|
|
11
|
+
* window (via terminal.openInNewTerminal) inside the project folder, never in
|
|
12
|
+
* the TUI. When a `tee` binary is available the agent command is piped through
|
|
13
|
+
* it into a per-project log file, and that file can be tailed back into the
|
|
14
|
+
* dashboard's OUTPUT pane with tailAgentLog(). On systems without tee the
|
|
15
|
+
* agent still launches; only the log capture is skipped.
|
|
16
|
+
*
|
|
17
|
+
* Every function degrades silently (returns a null/<ok:false> shape) so a
|
|
18
|
+
* missing binary, a locked logfile or a deleted project never crashes the TUI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { which } = require('./util');
|
|
26
|
+
const { openInNewTerminal } = require('./terminal');
|
|
27
|
+
|
|
28
|
+
/** The agents the dashboard offers a button/hotkey for. */
|
|
29
|
+
const AGENT_COMMANDS = {
|
|
30
|
+
claude: 'claude',
|
|
31
|
+
codex: 'codex',
|
|
32
|
+
opencode: 'opencode',
|
|
33
|
+
freebuff: 'freebuff',
|
|
34
|
+
kilocode: 'kilocode',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Per-newest-session tail state: key -> { watcher, lastSize, offset }. */
|
|
38
|
+
const tailing = new Map();
|
|
39
|
+
|
|
40
|
+
/** Log files live under ~/.termdeck/agents so tee writes always succeed. */
|
|
41
|
+
function logDir() {
|
|
42
|
+
return path.join(os.homedir(), '.termdeck', 'agents');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** project.name + agent -> a filesystem-safe log file name. */
|
|
46
|
+
function logFileName(project, agentName) {
|
|
47
|
+
const base = `${String(project.name || 'project').replace(/[^a-zA-Z0-9._-]+/g, '_')}-${String(agentName)}`;
|
|
48
|
+
return `${base}.log`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function logFilePath(project, agentName) {
|
|
52
|
+
return path.join(logDir(), logFileName(project, agentName));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `claude | tee -a <file>` — or just the bare command on systems without tee. */
|
|
56
|
+
function buildAgentCommand(project, agentName, { platform = process.platform } = {}) {
|
|
57
|
+
const command = (project.agents && project.agents[agentName]) || AGENT_COMMANDS[agentName] || agentName;
|
|
58
|
+
if (platform !== 'win32' || which('tee.exe')) {
|
|
59
|
+
fs.mkdirSync(logDir(), { recursive: true });
|
|
60
|
+
return { command: `${command} 2>&1 | tee -a ${shellSafe(logFilePath(project, agentName))}`, logFile: logFilePath(project, agentName) };
|
|
61
|
+
}
|
|
62
|
+
return { command, logFile: logFilePath(project, agentName) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Quote a path for the POSIX side of a pipe. */
|
|
66
|
+
function shellSafe(filePath) {
|
|
67
|
+
return `"${String(filePath).replace(/"/g, '\\"')}"`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Launch an agent for a project in a new terminal window.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} project
|
|
74
|
+
* @param {string} agentName one of claude / codex / opencode / freebuff / kilocode
|
|
75
|
+
* @param {object} [options] { terminal: fn, platform, dryRun }
|
|
76
|
+
* @returns {Promise<{ok:boolean, terminal?:string, command?:string, logFile?:string, error?:string}>}
|
|
77
|
+
*/
|
|
78
|
+
async function launchAgent(project, agentName, options = {}) {
|
|
79
|
+
if (!project || !project.path) return { ok: false, error: 'no project to launch an agent in' };
|
|
80
|
+
if (!AGENT_COMMANDS[agentName]) return { ok: false, error: `unknown agent "${agentName}"` };
|
|
81
|
+
|
|
82
|
+
const launch = options.terminal || openInNewTerminal;
|
|
83
|
+
const capable = options.platform || process.platform;
|
|
84
|
+
const wrapper = buildAgentCommand(project, agentName, { platform: capable });
|
|
85
|
+
const base = { logFile: wrapper.logFile };
|
|
86
|
+
|
|
87
|
+
// Test hook: hand back the exact command we would run, nothing is spawned.
|
|
88
|
+
if (options.commandOnly) return { ...base, command: wrapper.command };
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = await launch({ cwd: project.path, command: wrapper.command, platform: capable });
|
|
92
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
93
|
+
return { ok: true, terminal: result.terminal, command: result.command, logFile: wrapper.logFile };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, error: err.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start reading `project`'s agent log file, streaming appended lines (trimmed)
|
|
101
|
+
* to `callback`. Rotates to a fresh tail when the file is recreated. Returns a
|
|
102
|
+
* stop function; calling it (or stopAgent) unwatches the file.
|
|
103
|
+
*/
|
|
104
|
+
function tailAgentLog(project, agentName, callback, options = {}) {
|
|
105
|
+
const key = tailKey(project, agentName);
|
|
106
|
+
const file = path.join(options.logDir || logDir(), logFileName(project, agentName));
|
|
107
|
+
if (!callback || typeof callback !== 'function') return () => {};
|
|
108
|
+
stopAgent(project, agentName, { logDir: options.logDir });
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
112
|
+
} catch (_) {
|
|
113
|
+
return () => {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const state = { file, size: safeSize(file), offset: 0, timer: null, lastRead: 0 };
|
|
117
|
+
tailing.set(key, state);
|
|
118
|
+
|
|
119
|
+
// fs.watch is flaky across editors/OSes; poll as a cross-platform fallback.
|
|
120
|
+
const tick = () => readTail(state, callback);
|
|
121
|
+
state.timer = setInterval(tick, options.intervalMs || 1000);
|
|
122
|
+
if (state.timer.unref) state.timer.unref();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const watcher = fs.watch(file, { persistent: false }, () => readTail(state, callback));
|
|
126
|
+
watcher.on('error', () => {}); // swallow EPERM etc. on Windows temp dirs
|
|
127
|
+
} catch (_) {
|
|
128
|
+
/* polling still covers it */
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return () => stopAgent(project, agentName, { logDir: options.logDir });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function tailKey(project, agentName) {
|
|
135
|
+
return `${project.path}\u0000${agentName}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function safeSize(file) {
|
|
139
|
+
try {
|
|
140
|
+
return fs.statSync(file).size;
|
|
141
|
+
} catch (_) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Emit any bytes that appear past the last read position. */
|
|
147
|
+
function readTail(state, callback) {
|
|
148
|
+
let fd;
|
|
149
|
+
try {
|
|
150
|
+
const current = safeSize(state.file);
|
|
151
|
+
if (current < state.size) {
|
|
152
|
+
// File was recreated (session closed, new session opened).
|
|
153
|
+
state.offset = 0;
|
|
154
|
+
state.size = current;
|
|
155
|
+
}
|
|
156
|
+
if (current <= state.offset) {
|
|
157
|
+
state.size = current;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fd = fs.openSync(state.file, 'r');
|
|
162
|
+
const buffer = Buffer.alloc(current - state.offset);
|
|
163
|
+
fs.readSync(fd, buffer, 0, buffer.length, state.offset);
|
|
164
|
+
state.offset = current;
|
|
165
|
+
state.size = current;
|
|
166
|
+
|
|
167
|
+
const text = String(buffer).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
168
|
+
for (const line of text.split('\n')) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (trimmed) callback(trimmed);
|
|
171
|
+
}
|
|
172
|
+
} catch (_) {
|
|
173
|
+
/* file not readable yet — try again on the next tick */
|
|
174
|
+
} finally {
|
|
175
|
+
if (fd !== undefined) {
|
|
176
|
+
try {
|
|
177
|
+
fs.closeSync(fd);
|
|
178
|
+
} catch (_) {
|
|
179
|
+
/* already closed */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Stop tailing one project/agent pair (idempotent). */
|
|
186
|
+
function stopAgent(project, agentName, options = {}) {
|
|
187
|
+
const key = tailKey(project, agentName);
|
|
188
|
+
const state = tailing.get(key);
|
|
189
|
+
if (!state) return false;
|
|
190
|
+
tailing.delete(key);
|
|
191
|
+
if (state.timer) clearInterval(state.timer);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stopAllAgents() {
|
|
196
|
+
for (const key of [...tailing.keys()]) {
|
|
197
|
+
const [projectPath, agentName] = key.split('\u0000');
|
|
198
|
+
stopAgent({ path: projectPath }, agentName);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isTailing(project, agentName) {
|
|
203
|
+
return tailing.has(tailKey(project, agentName));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
AGENT_COMMANDS,
|
|
208
|
+
logDir,
|
|
209
|
+
logFileName,
|
|
210
|
+
logFilePath,
|
|
211
|
+
buildAgentCommand,
|
|
212
|
+
launchAgent,
|
|
213
|
+
tailAgentLog,
|
|
214
|
+
stopAgent,
|
|
215
|
+
stopAllAgents,
|
|
216
|
+
isTailing,
|
|
217
|
+
};
|