agenter-app 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -130
- package/cli.js +43 -0
- package/lib/memory.js +101 -0
- package/lib/terminal.js +127 -0
- package/lib/update.js +69 -0
- package/package.json +1 -1
- package/public/app.js +500 -21
- package/public/index.html +89 -10
- package/public/style.css +119 -10
- package/scripts/pick-folder.ps1 +85 -0
- package/server.js +205 -17
package/lib/terminal.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The dashboard's terminal: one long-lived PowerShell per folder, so `cd` and variables
|
|
3
|
+
// carry over between commands. It talks over plain pipes (a real pseudo-terminal would need
|
|
4
|
+
// a native dependency), so a program that takes over the screen, like vim, won't work.
|
|
5
|
+
// Each command ends by printing a marker with a per-shell secret, which is how we learn
|
|
6
|
+
// it finished, its exit code and the folder it left us in.
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { spawn, execFile } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const KEEP_CHARS = 200 * 1024;
|
|
11
|
+
const SHELL_ARGS = ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-OutputFormat', 'Text', '-Command', '-'];
|
|
12
|
+
const SETUP = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding\n";
|
|
13
|
+
|
|
14
|
+
// One line, so nothing the command reads from stdin (Read-Host, say) can swallow the marker.
|
|
15
|
+
// Base64 keeps quotes and newlines in what you typed from reaching this line at all.
|
|
16
|
+
function commandLine(command, marker) {
|
|
17
|
+
const b64 = Buffer.from(command, 'utf8').toString('base64');
|
|
18
|
+
return `try { . ([scriptblock]::Create([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${b64}')))) } catch { [Console]::Error.WriteLine(($_ | Out-String).Trim()) }; [Console]::Out.WriteLine("\`n${marker}\${LASTEXITCODE}:$($PWD.ProviderPath)>>")\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pulls finished-command markers out of buffered output. Returns the output that's safe to
|
|
22
|
+
// show now, what to keep for next time, and any markers found.
|
|
23
|
+
function splitOutput(buf, marker) {
|
|
24
|
+
const done = [];
|
|
25
|
+
let shown = '';
|
|
26
|
+
for (;;) {
|
|
27
|
+
const i = buf.indexOf(marker);
|
|
28
|
+
if (i < 0) break;
|
|
29
|
+
const end = buf.indexOf('>>', i);
|
|
30
|
+
if (end < 0) return { shown: shown + buf.slice(0, i), rest: buf.slice(i), done };
|
|
31
|
+
const [code, ...cwd] = buf.slice(i + marker.length, end).split(':');
|
|
32
|
+
done.push({ code, cwd: cwd.join(':') });
|
|
33
|
+
shown += buf.slice(0, i).replace(/\r?\n$/, '');
|
|
34
|
+
buf = buf.slice(end + 2).replace(/^\r?\n/, '');
|
|
35
|
+
}
|
|
36
|
+
// The start of a marker may have landed at the very end of this chunk.
|
|
37
|
+
for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
|
|
38
|
+
if (buf.endsWith(marker.slice(0, n))) return { shown: shown + buf.slice(0, -n), rest: buf.slice(-n), done };
|
|
39
|
+
}
|
|
40
|
+
return { shown: shown + buf, rest: '', done };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createTerminals(onEvent) {
|
|
44
|
+
const terms = new Map(); // id -> { id, cwd, child, running, marker, buf, output, onDone }
|
|
45
|
+
|
|
46
|
+
const emit = (t, data = '') => {
|
|
47
|
+
if (data) t.output = (t.output + data).slice(-KEEP_CHARS);
|
|
48
|
+
onEvent({ id: t.id, data, running: t.running, cwd: t.cwd });
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function startShell(t) {
|
|
52
|
+
t.marker = `<<agenter:${crypto.randomBytes(8).toString('hex')}:`;
|
|
53
|
+
t.buf = '';
|
|
54
|
+
const child = spawn('powershell.exe', SHELL_ARGS, {
|
|
55
|
+
cwd: t.cwd,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
58
|
+
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', npm_config_progress: 'false' },
|
|
59
|
+
});
|
|
60
|
+
t.child = child;
|
|
61
|
+
child.stdin.on('error', () => {});
|
|
62
|
+
child.stdout.setEncoding('utf8');
|
|
63
|
+
child.stderr.setEncoding('utf8');
|
|
64
|
+
child.stderr.on('data', (d) => emit(t, d));
|
|
65
|
+
child.stdout.on('data', (d) => {
|
|
66
|
+
const { shown, rest, done } = splitOutput(t.buf + d, t.marker);
|
|
67
|
+
t.buf = rest;
|
|
68
|
+
if (shown) emit(t, shown);
|
|
69
|
+
for (const { code, cwd } of done) {
|
|
70
|
+
t.running = false;
|
|
71
|
+
if (cwd) t.cwd = cwd;
|
|
72
|
+
emit(t, shown.endsWith('\n') || !shown ? '' : '\n');
|
|
73
|
+
const after = t.onDone;
|
|
74
|
+
t.onDone = null;
|
|
75
|
+
after?.(code);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
child.on('error', (e) => emit(t, `Couldn't start PowerShell: ${e.message}\n`));
|
|
79
|
+
child.on('close', () => {
|
|
80
|
+
if (t.child !== child) return;
|
|
81
|
+
t.child = null;
|
|
82
|
+
t.running = false;
|
|
83
|
+
t.onDone = null;
|
|
84
|
+
emit(t, t.stopping ? '\n[stopped]\n' : '\n[shell exited]\n');
|
|
85
|
+
t.stopping = false;
|
|
86
|
+
});
|
|
87
|
+
child.stdin.write(SETUP);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
open(id, cwd) {
|
|
92
|
+
let t = terms.get(id);
|
|
93
|
+
if (!t) terms.set(id, (t = { id, cwd, child: null, running: false, marker: '', buf: '', output: '', onDone: null, stopping: false }));
|
|
94
|
+
return { id: t.id, cwd: t.cwd, running: t.running, output: t.output };
|
|
95
|
+
},
|
|
96
|
+
// Sends a command, or if one is running, a line to that command's stdin.
|
|
97
|
+
run(id, input, onDone) {
|
|
98
|
+
const t = terms.get(id);
|
|
99
|
+
if (!t) return false;
|
|
100
|
+
if (t.running) {
|
|
101
|
+
emit(t, `${input}\n`);
|
|
102
|
+
t.child.stdin.write(`${input}\n`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!t.child) startShell(t);
|
|
106
|
+
t.running = true;
|
|
107
|
+
t.onDone = onDone || null;
|
|
108
|
+
emit(t, `PS ${t.cwd}> ${input}\n`);
|
|
109
|
+
t.child.stdin.write(commandLine(input, t.marker));
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
// PowerShell over pipes has no Ctrl+C, so this ends the shell along with what it's running.
|
|
113
|
+
// The next command starts a new one in the same folder.
|
|
114
|
+
stop(id) {
|
|
115
|
+
const t = terms.get(id);
|
|
116
|
+
if (!t?.child) return false;
|
|
117
|
+
t.stopping = true;
|
|
118
|
+
execFile('taskkill', ['/pid', String(t.child.pid), '/T', '/F'], { windowsHide: true }, (err) => err && t.child?.kill());
|
|
119
|
+
return true;
|
|
120
|
+
},
|
|
121
|
+
killAll() {
|
|
122
|
+
for (const t of terms.values()) t.child?.kill();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { createTerminals, commandLine, splitOutput };
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Is there a newer Agenter on npm? Used by the `agenter` command on every run and by the
|
|
3
|
+
// server for the dashboard. The answer is cached in ~/.agenter/update.json so being offline
|
|
4
|
+
// still shows the last one. AGENTER_UPDATE_CHECK=0 turns all of this off.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const PACKAGE = 'agenter-app';
|
|
10
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
11
|
+
const ROOT = path.join(__dirname, '..');
|
|
12
|
+
|
|
13
|
+
const enabled = () => process.env.AGENTER_UPDATE_CHECK !== '0';
|
|
14
|
+
const cacheFile = () => path.join(os.homedir(), '.agenter', 'update.json');
|
|
15
|
+
// Also what makes a version safe to put on an npm command line.
|
|
16
|
+
const isVersion = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
|
|
17
|
+
|
|
18
|
+
function isNewer(a, b) {
|
|
19
|
+
if (!isVersion(a) || !isVersion(b)) return false;
|
|
20
|
+
const [x, y] = [a, b].map((v) => v.split('.').map(Number));
|
|
21
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cachedLatest() {
|
|
26
|
+
try {
|
|
27
|
+
const { latest } = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
|
|
28
|
+
return isVersion(latest) ? latest : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The newest published version, or the last one seen if npm can't be reached.
|
|
35
|
+
async function checkLatest({ timeoutMs = 1500 } = {}) {
|
|
36
|
+
if (!enabled()) return null;
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetch(process.env.AGENTER_UPDATE_URL || REGISTRY_URL, { signal: AbortSignal.timeout(timeoutMs) });
|
|
39
|
+
if (!r.ok) return cachedLatest();
|
|
40
|
+
const { version } = await r.json();
|
|
41
|
+
if (!isVersion(version)) return cachedLatest();
|
|
42
|
+
fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
|
|
43
|
+
fs.writeFileSync(cacheFile(), JSON.stringify({ latest: version, checkedAt: Date.now() }, null, 2));
|
|
44
|
+
return version;
|
|
45
|
+
} catch {
|
|
46
|
+
return cachedLatest();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// What's on disk now. A running server keeps the version it started with, so a difference
|
|
51
|
+
// means an update was installed and it needs a restart.
|
|
52
|
+
function installedVersion() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A git clone updates with git. Installing over it from npm would land somewhere else.
|
|
61
|
+
const canSelfUpdate = () => !fs.existsSync(path.join(ROOT, '.git'));
|
|
62
|
+
|
|
63
|
+
const updateCommand = () => (canSelfUpdate() ? 'agenter update' : 'git pull');
|
|
64
|
+
|
|
65
|
+
function notice(current, latest) {
|
|
66
|
+
return isNewer(latest, current) ? `Agenter ${latest} is available (you have ${current}). Run: ${updateCommand()}` : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { PACKAGE, enabled, isVersion, isNewer, cachedLatest, checkLatest, installedVersion, canSelfUpdate, updateCommand, notice };
|