@lardexx/arenacode 0.0.0-stage → 2.5.3

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/src/turn.js ADDED
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ const { c, SYMBOL, ICONS, truncate, width, write } = require('./ui');
4
+ const { handleLocalExec, handleLocalFs, handleLocalUi, handleLocalFile } = require('./local');
5
+
6
+ /**
7
+ * Renders the Agent Mode timeline: long-poll GET /task/<id>/events?format=json&since=N&wait=25
8
+ * and answers the cards the client is expected to execute (terminal, files, plan, files sent
9
+ * by the agent).
10
+ */
11
+ class Timeline {
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ this.seen = new Map(); // seq -> {kind, printed}
15
+ this.textOpen = false;
16
+ this.handled = new Set(); // rids already executed
17
+ this.finalText = '';
18
+ this.sawText = false;
19
+ this.lastActivity = Date.now();
20
+ }
21
+
22
+ newlineIfText() {
23
+ if (this.textOpen) {
24
+ process.stdout.write('\n');
25
+ this.textOpen = false;
26
+ }
27
+ }
28
+
29
+ printText(delta) {
30
+ if (!delta) return;
31
+ const text = String(delta).replace(/\\n/g, '\n');
32
+ if (!this.textOpen) {
33
+ process.stdout.write(c.cyan(' │ ') + c.white(''));
34
+ this.textOpen = true;
35
+ }
36
+ const parts = text.split('\n');
37
+ process.stdout.write(c.white(parts[0]));
38
+ for (let i = 1; i < parts.length; i += 1) process.stdout.write('\n' + c.cyan(' │ ') + c.white(parts[i]));
39
+ this.finalText += text;
40
+ this.sawText = true;
41
+ }
42
+
43
+ row(icon, label, { tone = 'cyan', extra = '' } = {}) {
44
+ const body = `${c[tone](icon)} ${label}${extra}`;
45
+ write(' ' + body);
46
+ }
47
+
48
+ printDetail(detail) {
49
+ if (!detail) return;
50
+ const lines = String(detail).split('\n');
51
+ const max = 14;
52
+ for (const line of lines.slice(0, max)) write(c.gray(' ' + truncate(line, Math.max(40, (process.stdout.columns || 100) - 6))));
53
+ if (lines.length > max) write(c.gray(` … ${lines.length - max} more lines`));
54
+ }
55
+
56
+ render(ev) {
57
+ this.lastActivity = Date.now();
58
+ const kind = ev.kind || 'status';
59
+ const prev = this.seen.get(ev.seq);
60
+ const isUpdate = Boolean(prev) && prev.kind === kind;
61
+
62
+ if (kind !== 'text') this.newlineIfText();
63
+
64
+ switch (kind) {
65
+ case 'text':
66
+ this.printText(ev.delta || '');
67
+ break;
68
+
69
+ case 'thought':
70
+ if (isUpdate && ev.state === 'done') break;
71
+ this.row(SYMBOL.thought, c.dim(ev.label || '…'), { icon: 'dim', tone: 'dim' });
72
+ break;
73
+
74
+ case 'tool': {
75
+ const icon = ICONS[ev.icon] || SYMBOL.tool;
76
+ const label = ev.label || ev.tool || 'step';
77
+ const secs = ev.secs ? c.gray(` ${Number(ev.secs).toFixed(1)}s`) : '';
78
+ const tone = ev.state === 'failed' ? 'red' : ev.state === 'done' ? 'green' : 'yellow';
79
+ this.row(icon, c.bold(label), { tone, extra: secs });
80
+ if (ev.detail) this.printDetail(ev.detail);
81
+ break;
82
+ }
83
+
84
+ case 'status':
85
+ this.row(SYMBOL.status, c.gray(ev.label || ''));
86
+ break;
87
+
88
+ case 'vision':
89
+ this.row(SYMBOL.vision, c.gray(`vision: ${ev.vision === true ? 'yes' : 'no'}`), { tone: 'magenta' });
90
+ break;
91
+
92
+ case 'user':
93
+ this.row(SYMBOL.user, c.yellow(`${ev.label || 'user event'} ${ev.detail || ''}`), { tone: 'yellow' });
94
+ break;
95
+
96
+ case 'error':
97
+ this.row(SYMBOL.error, c.red(ev.label || ev.error || 'error'), { tone: 'red' });
98
+ if (ev.detail) this.printDetail(ev.detail);
99
+ break;
100
+
101
+ case 'done': {
102
+ const secs = ev.secs ? c.gray(` ${Number(ev.secs).toFixed(1)}s`) : '';
103
+ if (ev.state === 'cancelled') this.row('■', c.yellow('cancelled'), { tone: 'yellow', extra: secs });
104
+ else if (ev.state === 'error') this.row(SYMBOL.error, c.red('failed'), { tone: 'red', extra: secs });
105
+ else this.row(SYMBOL.done, c.green('done'), { tone: 'green', extra: secs });
106
+ break;
107
+ }
108
+
109
+ default:
110
+ this.row(SYMBOL.status, c.gray(`${kind} ${ev.label || ''}`));
111
+ }
112
+
113
+ this.seen.set(ev.seq, { kind });
114
+ }
115
+
116
+ /** Executes the cards the client owns. Runs at most once per rid. */
117
+ async handle(ev, bridge, taskId) {
118
+ const { local_exec: localExec, local_fs: localFs, local_ui: localUi, local_file: localFile } = ev;
119
+ try {
120
+ if (localExec && localExec.rid && !this.handled.has(localExec.rid)) {
121
+ this.handled.add(localExec.rid);
122
+ return handleLocalExec(bridge, taskId, localExec, this.opts);
123
+ }
124
+ if (localFs && localFs.rid && !this.handled.has(localFs.rid)) {
125
+ this.handled.add(localFs.rid);
126
+ return handleLocalFs(bridge, taskId, localFs, this.opts);
127
+ }
128
+ if (localUi && localUi.rid && !this.handled.has(localUi.rid)) {
129
+ this.handled.add(localUi.rid);
130
+ return handleLocalUi(bridge, taskId, localUi, this.opts);
131
+ }
132
+ if (localFile && !this.handled.has(`file:${localFile.remote}:${localFile.dest || localFile.name}`)) {
133
+ this.handled.add(`file:${localFile.remote}:${localFile.dest || localFile.name}`);
134
+ return handleLocalFile(bridge, taskId, localFile, this.opts);
135
+ }
136
+ } catch (e) {
137
+ write(c.red(` ✗ local action failed: ${e.message}`));
138
+ }
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Follows one task until it finishes. Returns {state, output, error}.
145
+ * `stop()` can be used from a SIGINT handler to cancel the turn.
146
+ */
147
+ async function followTask(bridge, taskId, opts, onTaskId) {
148
+ const timeline = new Timeline(opts);
149
+ let since = 0;
150
+ let done = false;
151
+ let state = 'pending';
152
+ let output = '';
153
+ let error = null;
154
+ const idleTimeout = (opts.idleTimeout || 180) * 1000;
155
+
156
+ if (onTaskId) onTaskId(taskId);
157
+
158
+ for (;;) {
159
+ const res = await bridge.events(taskId, since, 25);
160
+
161
+ if (res.status === 0 || res.status >= 500) {
162
+ // transient: the client retries instead of dropping the turn
163
+ write(c.yellow(` ! ${(res.json && res.json.error) || res.status}; retrying in 3s`));
164
+ await new Promise((r) => setTimeout(r, 3000));
165
+ continue;
166
+ }
167
+ if (res.status === 404) {
168
+ return { state: 'error', output: '', error: 'task not found on the bridge' };
169
+ }
170
+ if (res.status !== 200 || !res.json) {
171
+ write(c.red(` ✗ events: HTTP ${res.status} ${res.text || ''}`));
172
+ return { state: 'error', output: '', error: `HTTP ${res.status}` };
173
+ }
174
+
175
+ const payload = res.json;
176
+ const events = Array.isArray(payload.events) ? payload.events : [];
177
+ for (const ev of events) {
178
+ if (ev.kind === 'done') {
179
+ done = true;
180
+ state = ev.state || state;
181
+ output = ev.output || output;
182
+ error = ev.error || null;
183
+ }
184
+ timeline.render(ev);
185
+ await timeline.handle(ev, bridge, taskId);
186
+ }
187
+ if (typeof payload.next === 'number') since = payload.next;
188
+
189
+ if (payload.done) {
190
+ state = payload.state || state;
191
+ if (!output) {
192
+ const t = await bridge.getTask(taskId);
193
+ if (t.status === 200 && t.json) {
194
+ output = t.json.output || '';
195
+ error = t.json.error || error;
196
+ }
197
+ }
198
+ break;
199
+ }
200
+ if (done) break;
201
+
202
+ if (Date.now() - timeline.lastActivity > idleTimeout) {
203
+ timeline.newlineIfText();
204
+ write(c.yellow(` ! no activity for ${Math.round(idleTimeout / 1000)}s: closing the session (the agent may still be working)`));
205
+ break;
206
+ }
207
+ }
208
+
209
+ timeline.newlineIfText();
210
+ return { state, output, error, streamed: timeline.sawText, timeline };
211
+ }
212
+
213
+ module.exports = { Timeline, followTask };
package/src/ui.js ADDED
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const readline = require('node:readline');
4
+ const { stdin, stdout } = require('node:process');
5
+
6
+ const useColor = stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
7
+
8
+ function wrap(code) {
9
+ return (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
10
+ }
11
+
12
+ const c = {
13
+ dim: wrap('2'),
14
+ bold: wrap('1'),
15
+ gray: wrap('90'),
16
+ red: wrap('31'),
17
+ green: wrap('32'),
18
+ yellow: wrap('33'),
19
+ blue: wrap('34'),
20
+ magenta: wrap('35'),
21
+ cyan: wrap('36'),
22
+ white: wrap('37'),
23
+ inverse: wrap('7'),
24
+ };
25
+
26
+ const SYMBOL = {
27
+ thought: '·',
28
+ tool: '›',
29
+ text: '│',
30
+ status: '~',
31
+ done: '✓',
32
+ error: '✗',
33
+ user: '←',
34
+ vision: '◉',
35
+ };
36
+
37
+ const ICONS = {
38
+ globe: '🌐', terminal: '⌘', doc: '▤', eye: '◉', search: '⌕',
39
+ image: '▣', sparkle: '✦', bolt: '⚡', check: '✓', x: '✗', folder: '▸', pencil: '✎',
40
+ };
41
+
42
+ function stripAnsi(s) {
43
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
44
+ }
45
+
46
+ /** Visual width, counting wide CJK/emoji as 2 cells. */
47
+ function width(s) {
48
+ let w = 0;
49
+ for (const ch of stripAnsi(s)) {
50
+ const cp = ch.codePointAt(0);
51
+ w += cp > 0x1100 && (cp <= 0x115f || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
52
+ (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
53
+ (cp >= 0x1f300 && cp <= 0x1f9ff)) ? 2 : 1;
54
+ }
55
+ return w;
56
+ }
57
+
58
+ function truncate(s, max) {
59
+ const text = String(s);
60
+ if (width(text) <= max) return text;
61
+ let out = '';
62
+ let w = 0;
63
+ for (const ch of stripAnsi(text)) {
64
+ const cw = width(ch);
65
+ if (w + cw > max - 1) break;
66
+ out += ch;
67
+ w += cw;
68
+ }
69
+ return out + '…';
70
+ }
71
+
72
+ function indentLines(text, prefix) {
73
+ return String(text)
74
+ .split('\n')
75
+ .map((line) => prefix + line)
76
+ .join('\n');
77
+ }
78
+
79
+ /** Asks a question and resolves with the trimmed answer ('' when stdin is not a TTY). */
80
+ function ask(question, { default: def = '', hidden = false } = {}) {
81
+ if (!stdin.isTTY) {
82
+ const chunks = [];
83
+ return new Promise((resolve) => {
84
+ stdin.on('data', (d) => chunks.push(d));
85
+ stdin.on('end', () => resolve(String(Buffer.concat(chunks)).trim() || def));
86
+ });
87
+ }
88
+ const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true });
89
+ return new Promise((resolve) => {
90
+ rl.question(question, (answer) => {
91
+ rl.close();
92
+ resolve(String(answer).trim() || def);
93
+ });
94
+ });
95
+ }
96
+
97
+ /** Single-key question: returns the pressed key, lowercased. */
98
+ function askKey(question, allowed) {
99
+ process.stdout.write(question);
100
+ return new Promise((resolve) => {
101
+ if (!stdin.isTTY) {
102
+ resolve(allowed[0]);
103
+ return;
104
+ }
105
+ stdin.setRawMode(true);
106
+ stdin.resume();
107
+ const onData = (buf) => {
108
+ const ch = String(buf).toLowerCase();
109
+ if (!allowed.includes(ch)) return;
110
+ stdin.setRawMode(false);
111
+ stdin.pause();
112
+ stdin.removeListener('data', onData);
113
+ process.stdout.write('\n');
114
+ resolve(ch);
115
+ };
116
+ stdin.on('data', onData);
117
+ });
118
+ }
119
+
120
+ function write(s = '') {
121
+ process.stdout.write(s + '\n');
122
+ }
123
+
124
+ function banner(version) {
125
+ write('');
126
+ write(` ${c.cyan(c.bold('ArenaCode CLI'))} ${c.dim('v' + version)} ${c.gray('· terminal client for arena-bridge')}`);
127
+ write('');
128
+ }
129
+
130
+ module.exports = { c, SYMBOL, ICONS, useColor, stripAnsi, width, truncate, indentLines, ask, askKey, write, banner };