@firefunc-agent/runner 0.5.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/dist/viewer.js ADDED
@@ -0,0 +1,172 @@
1
+ import { createServer } from 'node:http';
2
+ import { makeStreamJsonParser } from './session-stream.js';
3
+ const MAX_EVENTS = 5_000;
4
+ const MAX_SESSIONS = 50;
5
+ function sse(res, event, data) {
6
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
7
+ }
8
+ export function createSessionViewer(opts) {
9
+ const sessions = new Map();
10
+ const order = [];
11
+ const port = opts.port ?? 8787;
12
+ let url = null;
13
+ const server = createServer((req, res) => {
14
+ const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
15
+ if (path === '/healthz')
16
+ return void res.writeHead(200).end('ok');
17
+ if (path === '/')
18
+ return void res
19
+ .writeHead(200, { 'content-type': 'text/html' })
20
+ .end(indexHtml(listSessions()));
21
+ const m = /^\/s\/([^/]+?)(\/events)?$/.exec(path);
22
+ if (m) {
23
+ const runId = decodeURIComponent(m[1]);
24
+ const s = sessions.get(runId);
25
+ if (m[2] === '/events') {
26
+ res.writeHead(200, {
27
+ 'content-type': 'text/event-stream',
28
+ 'cache-control': 'no-cache',
29
+ connection: 'keep-alive',
30
+ });
31
+ if (!s)
32
+ return void sse(res, 'meta', { missing: true });
33
+ sse(res, 'meta', { title: s.title, repo: s.repo, status: s.status });
34
+ for (const ev of s.events)
35
+ sse(res, 'event', ev);
36
+ if (s.status !== 'running')
37
+ return void res.end();
38
+ s.clients.add(res);
39
+ req.on('close', () => s.clients.delete(res));
40
+ return;
41
+ }
42
+ return void res
43
+ .writeHead(200, { 'content-type': 'text/html' })
44
+ .end(sessionHtml(runId, s?.title ?? runId));
45
+ }
46
+ res.writeHead(404).end('not found');
47
+ });
48
+ server.on('error', (err) => {
49
+ url = null;
50
+ opts.onError?.(`session viewer could not start on :${port} (${err.message})`);
51
+ });
52
+ server.listen(port, '127.0.0.1', () => {
53
+ url = `http://127.0.0.1:${port}`;
54
+ opts.onListen?.(url);
55
+ });
56
+ function listSessions() {
57
+ return [...order]
58
+ .reverse()
59
+ .map((id) => sessions.get(id))
60
+ .filter((s) => !!s)
61
+ .map((s) => ({ runId: s.runId, title: s.title, status: s.status }));
62
+ }
63
+ function broadcast(s, event, data) {
64
+ for (const res of s.clients)
65
+ sse(res, event, data);
66
+ }
67
+ return {
68
+ get url() {
69
+ return url;
70
+ },
71
+ begin(runId, meta) {
72
+ if (sessions.has(runId))
73
+ return;
74
+ const s = {
75
+ runId,
76
+ title: meta.title ?? runId,
77
+ repo: meta.repo,
78
+ startedAt: Date.now(),
79
+ status: 'running',
80
+ events: [],
81
+ clients: new Set(),
82
+ feed: () => { },
83
+ };
84
+ s.feed = makeStreamJsonParser((ev) => {
85
+ s.events.push(ev);
86
+ if (s.events.length > MAX_EVENTS)
87
+ s.events.shift();
88
+ broadcast(s, 'event', ev);
89
+ });
90
+ sessions.set(runId, s);
91
+ order.push(runId);
92
+ while (order.length > MAX_SESSIONS) {
93
+ const old = order.shift();
94
+ sessions.get(old)?.clients.forEach((c) => c.end());
95
+ sessions.delete(old);
96
+ }
97
+ },
98
+ push(runId, chunk) {
99
+ sessions.get(runId)?.feed(chunk);
100
+ },
101
+ end(runId, status) {
102
+ const s = sessions.get(runId);
103
+ if (!s || s.status !== 'running')
104
+ return;
105
+ s.status = status;
106
+ broadcast(s, 'status', { status });
107
+ s.clients.forEach((c) => c.end());
108
+ s.clients.clear();
109
+ },
110
+ close() {
111
+ server.close();
112
+ },
113
+ };
114
+ }
115
+ const SHELL_CSS = `
116
+ :root{--bg:#0c0d10;--bar:#1c1e24;--ink:#e6e7ea;--dim:#8a8f99;--accent:#ff7a45;--ok:#34d399;--red:#ef4444}
117
+ *{box-sizing:border-box} body{margin:0;background:#06070a;color:var(--ink);
118
+ font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
119
+ .win{max-width:980px;margin:24px auto;border-radius:12px;overflow:hidden;
120
+ box-shadow:0 24px 60px rgba(0,0,0,.5);border:1px solid #20222a}
121
+ .bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--bar)}
122
+ .dot{width:12px;height:12px;border-radius:50%}.r{background:#ff5f57}.y{background:#febc2e}.g{background:#28c840}
123
+ .title{margin-left:8px;color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
124
+ .pill{margin-left:auto;font-size:11px;padding:2px 9px;border-radius:999px;background:#2a2d36;color:var(--dim)}
125
+ .pill.live{color:#fff;background:var(--accent)}.pill.live::before{content:"● "}
126
+ .pill.done{color:#06281c;background:var(--ok)}.pill.failed{color:#fff;background:var(--red)}
127
+ .body{background:var(--bg);padding:14px 16px;height:72vh;overflow:auto}
128
+ .ln{white-space:pre-wrap;word-break:break-word;padding:1px 0}
129
+ .ln.tool{color:var(--accent)} .ln.result{color:var(--ok);margin-top:6px;border-top:1px solid #20222a;padding-top:8px}
130
+ .ln.sys{color:var(--dim)} .cur{display:inline-block;width:8px;height:15px;background:var(--accent);
131
+ animation:b 1s steps(2) infinite;vertical-align:-2px}@keyframes b{50%{opacity:0}}
132
+ a{color:var(--accent)}`;
133
+ function page(title, inner, script = '') {
134
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
135
+ <title>${esc(title)}</title><style>${SHELL_CSS}</style></head><body>${inner}<script>${script}</script></body></html>`;
136
+ }
137
+ function sessionHtml(runId, title) {
138
+ const inner = `<div class="win"><div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
139
+ <span class="title">firefunc · ${esc(title)}</span><span id="pill" class="pill">connecting…</span></div>
140
+ <div class="body" id="body"></div></div>`;
141
+ const script = `(function(){
142
+ var body=document.getElementById('body'),pill=document.getElementById('pill');
143
+ function add(cls,txt){var d=document.createElement('div');d.className='ln '+cls;d.textContent=txt;body.appendChild(d);
144
+ body.scrollTop=body.scrollHeight;}
145
+ function setPill(t,c){pill.textContent=t;pill.className='pill '+(c||'');}
146
+ var es=new EventSource(${JSON.stringify('/s/' + encodeURIComponent(runId) + '/events')});
147
+ es.addEventListener('meta',function(e){var m=JSON.parse(e.data);
148
+ if(m.missing){setPill('no session','failed');add('sys','No live session for this run on this machine.');es.close();return;}
149
+ setPill(m.status==='running'?'live':m.status,m.status==='running'?'live':m.status);});
150
+ es.addEventListener('event',function(e){var v=JSON.parse(e.data);
151
+ if(v.kind==='tool'){add('tool','\\u2692 '+v.name+(v.arg?' '+v.arg:''));}
152
+ else if(v.kind==='result'){add('result',v.text);}
153
+ else{add('',v.text);}});
154
+ es.addEventListener('status',function(e){var s=JSON.parse(e.data).status;setPill(s,s);es.close();});
155
+ es.onerror=function(){setPill('disconnected','failed');};
156
+ })();`;
157
+ return page(title, inner, script);
158
+ }
159
+ function indexHtml(list) {
160
+ const rows = list.length
161
+ ? list
162
+ .map((s) => `<div class="ln"><a href="/s/${encodeURIComponent(s.runId)}">${esc(s.title)}</a> <span class="ln sys">— ${s.status}</span></div>`)
163
+ .join('')
164
+ : '<div class="ln sys">No sessions yet. They appear here when FireFunc routes work to this runner.</div>';
165
+ const inner = `<div class="win"><div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
166
+ <span class="title">firefunc · local sessions</span></div><div class="body">${rows}</div></div>`;
167
+ return page('firefunc · sessions', inner);
168
+ }
169
+ function esc(s) {
170
+ return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
171
+ }
172
+ //# sourceMappingURL=viewer.js.map
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@firefunc-agent/runner",
3
+ "version": "0.5.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "FireFunc self-hosted runner — an always-on daemon that runs YOUR local coding agent (Claude Code, Codex, Gemini CLI, Cursor CLI) on work FireFunc routes to it, opens pull requests, and reports back. Your machine, your subscriptions.",
7
+ "keywords": [
8
+ "firefunc",
9
+ "runner",
10
+ "daemon",
11
+ "claude-code",
12
+ "codex",
13
+ "gemini-cli",
14
+ "cursor",
15
+ "ai-agent",
16
+ "automation"
17
+ ],
18
+ "license": "SEE LICENSE IN LICENSE",
19
+ "bin": {
20
+ "firefunc-runner": "./dist/cli.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "!dist/**/*.map",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "dev": "tsx src/cli.ts",
36
+ "start": "tsx src/cli.ts start",
37
+ "build": "npm run clean && tsc -p .",
38
+ "prepack": "npm run build && node -e \"const{existsSync}=require('fs');if(!existsSync('dist/cli.js')){console.error('dist/cli.js is missing — refusing to pack a package with no code in it');process.exit(1)}\"",
39
+ "lint": "echo '[runner] lint TBD'",
40
+ "typecheck": "tsc -p . --noEmit",
41
+ "test": "vitest run --passWithNoTests",
42
+ "clean": "node -e \"const{rmSync}=require('fs');rmSync('dist',{recursive:true,force:true});rmSync('tsconfig.tsbuildinfo',{force:true})\""
43
+ },
44
+ "dependencies": {},
45
+ "devDependencies": {
46
+ "@types/node": "^22.10.0",
47
+ "tsx": "^4.22.2",
48
+ "typescript": "^5.6.0",
49
+ "vitest": "^4.1.6"
50
+ }
51
+ }