luang-cli 0.1.0 → 0.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 +335 -110
- package/bin/luang-cli.js +48 -17
- package/lib/agent.js +504 -79
- package/lib/commands.js +69 -3
- package/lib/core.js +8 -0
- package/lib/ws.js +552 -0
- package/package.json +4 -1
- package/test/bin/opencode +5 -0
- package/test/http-stub.js +39 -0
- package/test/run-e2e.js +458 -0
- package/test/ws-stub.js +202 -0
package/lib/agent.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// lib/agent.js — daemon agent
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// lib/agent.js — daemon agent: realtime WebSocket (docs/agent-realtime-ws.md),
|
|
4
|
+
// fallback polling HTTP lama, task list/create/status, status ringkas, mcp-config.
|
|
5
|
+
// Bagian lanjutan dari lib/commands.js.
|
|
6
|
+
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
// lib/ws bisa gagal dimuat di Node yang sangat tua → daemon tetap jalan via polling.
|
|
11
|
+
let wsLib = null;
|
|
12
|
+
try {
|
|
13
|
+
wsLib = require('./ws');
|
|
14
|
+
} catch (e) {
|
|
15
|
+
wsLib = null;
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
const core = require('./core');
|
|
9
19
|
const c = require('./commands');
|
|
@@ -12,8 +22,11 @@ const {
|
|
|
12
22
|
apiRequest,
|
|
13
23
|
loadState,
|
|
14
24
|
saveState,
|
|
25
|
+
saveConfig,
|
|
15
26
|
collectProviders,
|
|
27
|
+
heartbeatProviders,
|
|
16
28
|
DEFAULT_TEMPLATES,
|
|
29
|
+
DISPLAY_NAMES,
|
|
17
30
|
STATE_FILE,
|
|
18
31
|
CONFIG_DIR,
|
|
19
32
|
fmtWhen,
|
|
@@ -25,6 +38,9 @@ const {
|
|
|
25
38
|
|
|
26
39
|
const STATUS_LABELS = { todo: 'To-Do', in_progress: 'In Progress', review: 'Review', done: 'Done' };
|
|
27
40
|
|
|
41
|
+
const WS_PATH = '/api/v1/agent/ws'; // kontrak: docs/agent-realtime-ws.md §1
|
|
42
|
+
const REALTIME_GUARD_MS = 12000; // beri waktu beberapa percobaan WS sebelum fallback
|
|
43
|
+
|
|
28
44
|
// ── agent (daemon) ───────────────────────────────────────────────────────────
|
|
29
45
|
|
|
30
46
|
function stampNow() {
|
|
@@ -41,22 +57,42 @@ function taskIdFingerprint(t) {
|
|
|
41
57
|
function printTaskLine(t, tag) {
|
|
42
58
|
const code = c.taskIdOf(t) || '?';
|
|
43
59
|
let tail = '';
|
|
44
|
-
if (t.agent_status) tail += `
|
|
45
|
-
if (t.agent_progress_note) tail += (tail ? ' ' : '') +
|
|
46
|
-
|
|
60
|
+
if (t.agent_status) tail += `[${t.agent_status}]`;
|
|
61
|
+
if (t.agent_progress_note) tail += (tail ? ' ' : '') + t.agent_progress_note;
|
|
62
|
+
const stamp = tag ? ` (${stampNow()} ${trunc(tag, 16)})` : '';
|
|
63
|
+
console.log(`${code} ${trunc(t.title, 36)} ${t.status || '-'} ${tail}${stamp}`.replace(/\s+$/, ''));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Masukkan satu tugas ke cache state; true bila berubah (sesuai fingerprint).
|
|
67
|
+
function ingestTaskIntoState(t, state) {
|
|
68
|
+
const id = c.taskIdOf(t);
|
|
69
|
+
if (!id) return false;
|
|
70
|
+
const fp = taskIdFingerprint(t);
|
|
71
|
+
const cur = t.agent_updated_at || t.updated_at || null;
|
|
72
|
+
const prev = state.tasks[id];
|
|
73
|
+
const changed = !prev || prev.fp !== fp || (!prev.fp && prev.updated_at !== cur);
|
|
74
|
+
if (changed) state.tasks[id] = { updated_at: cur, fp, title: t.title, status: t.status };
|
|
75
|
+
return changed;
|
|
47
76
|
}
|
|
48
77
|
|
|
49
|
-
function buildExecCommand(tpl, taskText) {
|
|
78
|
+
function buildExecCommand(tpl, taskText, rawModel) {
|
|
50
79
|
if (!tpl || !tpl.includes('{task}')) return null;
|
|
80
|
+
let t = tpl;
|
|
81
|
+
if (t.includes('{model}')) {
|
|
82
|
+
// {model} diisi dari model provider (config/agent.command), fallback env LUANG_MODEL.
|
|
83
|
+
const model = String(rawModel || process.env.LUANG_MODEL || '').trim();
|
|
84
|
+
if (!model || !/^[\w.:+/-]+$/.test(model)) return null; // tanpa model → pemanggil menampilkan notifikasi
|
|
85
|
+
t = t.split('{model}').join(model);
|
|
86
|
+
}
|
|
51
87
|
let q;
|
|
52
|
-
if (
|
|
88
|
+
if (t.includes('"{task}"')) {
|
|
53
89
|
q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
|
|
54
|
-
} else if (
|
|
90
|
+
} else if (t.includes("'{task}'")) {
|
|
55
91
|
q = taskText.replace(/'/g, `'\\''`); // escape untuk kutip tunggal
|
|
56
92
|
} else {
|
|
57
93
|
q = taskText.replace(/(["`\\$\n])/g, ' '); // {task} tanpa kutip → rapikan
|
|
58
94
|
}
|
|
59
|
-
return
|
|
95
|
+
return t.replaceAll('{task}', q);
|
|
60
96
|
}
|
|
61
97
|
|
|
62
98
|
function runExecChild(cmd, setChild) {
|
|
@@ -64,13 +100,18 @@ function runExecChild(cmd, setChild) {
|
|
|
64
100
|
return new Promise((resolve) => {
|
|
65
101
|
let child = null;
|
|
66
102
|
try {
|
|
67
|
-
|
|
103
|
+
// stdio inherit → output provider (mis. "opencode run") tampil live di terminal.
|
|
104
|
+
child = spawn(cmd, [], { shell: true, stdio: 'inherit', windowsHide: true });
|
|
68
105
|
} catch (e) {
|
|
69
106
|
resolve(1);
|
|
70
107
|
return;
|
|
71
108
|
}
|
|
72
109
|
if (setChild) setChild(child);
|
|
110
|
+
const killer = setTimeout(() => {
|
|
111
|
+
try { child.kill('SIGKILL'); } catch (e) { /* abaikan */ }
|
|
112
|
+
}, EXEC_TIMEOUT_MS);
|
|
73
113
|
const fin = (code) => {
|
|
114
|
+
clearTimeout(killer);
|
|
74
115
|
if (setChild) setChild(null);
|
|
75
116
|
resolve(typeof code === 'number' ? code : 1);
|
|
76
117
|
};
|
|
@@ -79,12 +120,119 @@ function runExecChild(cmd, setChild) {
|
|
|
79
120
|
});
|
|
80
121
|
}
|
|
81
122
|
|
|
82
|
-
|
|
123
|
+
// ── Kanal transport ────────────────────────────────────────────────────────────
|
|
124
|
+
// 'ws' → kirim via frame WS (task.status / command.ack / telemetry.run)
|
|
125
|
+
// 'http' → perilaku lama: task status lewat POST /agent/tasks/:ref/status
|
|
126
|
+
|
|
127
|
+
function makeConsent(cfg) {
|
|
128
|
+
return {
|
|
129
|
+
local: cfg.telemetry === true, // config.telemetry — opt-in lokal
|
|
130
|
+
server: null, // telemetry_enabled dari hello.ok (server)
|
|
131
|
+
noticed: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Pemberitahuan konsent: sekali saja per sesi daemon (spek: never send + notice).
|
|
136
|
+
function noticeConsentOnce(consent) {
|
|
137
|
+
if (consent.noticed) return;
|
|
138
|
+
consent.noticed = true;
|
|
139
|
+
const l = consent.local === true ? 'lokal ✅' : 'lokal ❌ (luang-cli telemetry on)';
|
|
140
|
+
const s =
|
|
141
|
+
consent.server === true
|
|
142
|
+
? 'server ✅'
|
|
143
|
+
: consent.server === false
|
|
144
|
+
? 'server ❌ (nyalakan Telemetry di dashboard Agents)'
|
|
145
|
+
: 'server ? (info belum diterima)';
|
|
146
|
+
console.log(`ℹ️ Telemetry TIDAK dikirim (${l} · ${s}) — consent ganda harus ON bersamaan.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tokensMaybe(cfg) {
|
|
150
|
+
const out = {};
|
|
151
|
+
const pick = (envName, cfgVal) => {
|
|
152
|
+
const e = process.env[envName];
|
|
153
|
+
if (e !== undefined && String(e).trim() !== '') {
|
|
154
|
+
const n = Number(e);
|
|
155
|
+
if (Number.isFinite(n)) return n;
|
|
156
|
+
}
|
|
157
|
+
if (typeof cfgVal === 'number' && Number.isFinite(cfgVal)) return cfgVal;
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
const tin = pick('LUANG_TOKENS_IN', cfg.tokens_in);
|
|
161
|
+
const tout = pick('LUANG_TOKENS_OUT', cfg.tokens_out);
|
|
162
|
+
if (tin !== null) out.tokens_in = tin;
|
|
163
|
+
if (tout !== null) out.tokens_out = tout;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function makeChannel(kind, opts) {
|
|
168
|
+
const { rt, ws, consent } = opts;
|
|
169
|
+
const ch = {
|
|
170
|
+
kind,
|
|
171
|
+
rt,
|
|
172
|
+
ws,
|
|
173
|
+
consent,
|
|
174
|
+
paused: false, // dari agent.command pause/resume
|
|
175
|
+
latestTasks: [],
|
|
176
|
+
execChain: Promise.resolve(),
|
|
177
|
+
// Update status tugas; label dipakai untuk pesan error HTTP mode saja.
|
|
178
|
+
sendStatus(code, body, label) {
|
|
179
|
+
if (kind === 'ws' && this.ws) {
|
|
180
|
+
try {
|
|
181
|
+
this.ws.send({ type: 'task.status', ts: Date.now(), data: { ref: String(code), ...body } });
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error(`⚠️ Gagal kirim task.status: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
apiRequest(this.rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(String(code))}/status`, { body })
|
|
188
|
+
.catch((e) => console.error(`⚠️ Gagal ${label || 'update status tugas'}: ${e.message}`));
|
|
189
|
+
},
|
|
190
|
+
ack(commandId, ok, message) {
|
|
191
|
+
if (kind !== 'ws' || !this.ws) return;
|
|
192
|
+
const data = { command_id: commandId, ok: !!ok };
|
|
193
|
+
if (message) data.message = String(message);
|
|
194
|
+
try {
|
|
195
|
+
this.ws.send({ type: 'command.ack', ts: Date.now(), data });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`⚠️ Gagal kirim command.ack: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
// Telemetry.run — HANYA realtime; tanpa consent → didrop di CLI + notice sekali.
|
|
201
|
+
telemetry(payload) {
|
|
202
|
+
if (kind !== 'ws') return;
|
|
203
|
+
if (!(consent.local === true && consent.server === true)) {
|
|
204
|
+
noticeConsentOnce(consent);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
this.ws.send({ type: 'telemetry.run', ts: Date.now(), data: payload });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
console.error(`⚠️ Gagal kirim telemetry.run: ${e.message}`);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
return ch;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function tryExecTask(ch, tasks, state, execCfg, current) {
|
|
218
|
+
if (ch.paused) return false; // di-pause server → exec tidak dijalankan
|
|
83
219
|
const cand = tasks.find((t) => c.statusInputNorm(t.status) === 'todo' && !state.executed[c.taskIdOf(t)]);
|
|
84
220
|
if (!cand) return false;
|
|
85
221
|
const code = c.taskIdOf(cand);
|
|
86
222
|
const taskText = `${cand.title || ''}${cand.description ? ' — ' + cand.description : ''}`;
|
|
87
|
-
|
|
223
|
+
if (execCfg.template && execCfg.template.includes('{model}')) {
|
|
224
|
+
const model = String(execCfg.model || process.env.LUANG_MODEL || '').trim();
|
|
225
|
+
if (!model) {
|
|
226
|
+
// template memuat {model} tapi tidak ada model yang bisa dipakai → dilewati dengan notifikasi
|
|
227
|
+
state.executed[code] = true;
|
|
228
|
+
saveState(state);
|
|
229
|
+
console.log(
|
|
230
|
+
`⏭ ${code} menunggu (To-Do) — template memuat {model} tapi belum ada model. Set: provider add ${execCfg.type || '<type>'} --model <m>, server agent.command set_model, atau env LUANG_MODEL.`,
|
|
231
|
+
);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const cmdStr = buildExecCommand(execCfg.template, taskText, execCfg.model);
|
|
88
236
|
if (!cmdStr) {
|
|
89
237
|
// exec_template kosong / tanpa {task} → hanya notifikasi satu baris (spec)
|
|
90
238
|
state.executed[code] = true; // tandai agar tidak nag lagi
|
|
@@ -107,78 +255,296 @@ async function tryExecTask(rt, tasks, state, execCfg, current) {
|
|
|
107
255
|
saveState(state);
|
|
108
256
|
return true;
|
|
109
257
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
}
|
|
258
|
+
const model = String(execCfg.model || process.env.LUANG_MODEL || '') || undefined;
|
|
259
|
+
const toks = tokensMaybe(ch.rt.cfg);
|
|
260
|
+
const baseTel = { task_ref: code, task_code: code, provider: execCfg.type, model };
|
|
261
|
+
const t0 = Date.now();
|
|
262
|
+
// Spesifikasi: sebelum eksekusi, tandai in_progress dengan agent_status "menjalankan".
|
|
263
|
+
ch.sendStatus(code, { status: 'in_progress', agent_status: 'menjalankan' }, 'set in_progress');
|
|
264
|
+
ch.telemetry({ ...baseTel, status: 'started', ...toks });
|
|
118
265
|
const exitCode = await runExecChild(cmdStr, (child) => {
|
|
119
266
|
current.child = child;
|
|
120
267
|
});
|
|
121
268
|
const ok = exitCode === 0;
|
|
122
|
-
// Spesifikasi: exit 0 → {status:"done", agent_status:"selesai", progress_note:"exec
|
|
123
|
-
// selain itu → {status:"review", agent_status:"gagal"}.
|
|
269
|
+
// Spesifikasi: exit 0 → {status:"done", agent_status:"selesai", progress_note:"exec exit 0"};
|
|
270
|
+
// selain itu → {status:"review", agent_status:"gagal", progress_note:"exit code N"}.
|
|
271
|
+
ch.sendStatus(
|
|
272
|
+
code,
|
|
273
|
+
ok
|
|
274
|
+
? { status: 'done', agent_status: 'selesai', progress_note: 'exec exit 0' }
|
|
275
|
+
: { status: 'review', agent_status: 'gagal', progress_note: `exit code ${exitCode}` },
|
|
276
|
+
'update status tugas',
|
|
277
|
+
);
|
|
278
|
+
ch.telemetry({
|
|
279
|
+
...baseTel,
|
|
280
|
+
status: ok ? 'completed' : 'failed',
|
|
281
|
+
exit_code: exitCode,
|
|
282
|
+
duration_ms: Date.now() - t0,
|
|
283
|
+
...toks,
|
|
284
|
+
});
|
|
285
|
+
console.log(ok ? `✅ ${code} selesai (exit 0)` : `⚠️ ${code} gagal (exit ${exitCode}) → review`);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Rantai eksekusi: event datang serentak → anak antre, tidak dobel.
|
|
290
|
+
function runExecMaybe(ch, state, execCfg, current) {
|
|
291
|
+
ch.execChain = ch.execChain
|
|
292
|
+
.then(() => tryExecTask(ch, ch.latestTasks, state, execCfg, current))
|
|
293
|
+
.then((acted) => {
|
|
294
|
+
if (acted) saveState(state);
|
|
295
|
+
})
|
|
296
|
+
.catch(() => {});
|
|
297
|
+
return ch.execChain;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Encode waktu/config untuk WS ──────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
function wsRealtimeUrl(apiUrl) {
|
|
124
303
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
304
|
+
const u = new URL(String(apiUrl));
|
|
305
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
306
|
+
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
307
|
+
let p = u.pathname.replace(/\/+$/, '');
|
|
308
|
+
p = p.endsWith('/api/v1') ? p + '/agent/ws' : p + '/api/v1/agent/ws';
|
|
309
|
+
u.pathname = p;
|
|
310
|
+
u.search = '';
|
|
311
|
+
return u.toString();
|
|
130
312
|
} catch (e) {
|
|
131
|
-
|
|
313
|
+
return null;
|
|
132
314
|
}
|
|
133
|
-
console.log(ok ? `✅ ${code} selesai (exit 0)` : `⚠️ ${code} gagal (exit ${exitCode}) → review`);
|
|
134
|
-
return true;
|
|
135
315
|
}
|
|
136
316
|
|
|
137
|
-
|
|
138
|
-
if (!
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (intervalSec > 3600) intervalSec = 3600;
|
|
143
|
-
const doExec = flags.exec === true;
|
|
317
|
+
function canRealtime(rt) {
|
|
318
|
+
if (!wsLib) return false;
|
|
319
|
+
if (['1', 'true', 'yes'].includes(String(process.env.LUANG_NO_WS || '').toLowerCase())) return false;
|
|
320
|
+
return !!rt.key && !!wsRealtimeUrl(rt.apiUrl);
|
|
321
|
+
}
|
|
144
322
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
323
|
+
// providers untuk hello: kontrak WS pakai {type, exec_template?, model?}
|
|
324
|
+
function providersForHello(cfg) {
|
|
325
|
+
return collectProviders(cfg).map((p) => {
|
|
326
|
+
const o = { type: p.provider_type };
|
|
327
|
+
if (p.exec_template) o.exec_template = String(p.exec_template);
|
|
328
|
+
if (p.model) o.model = String(p.model);
|
|
329
|
+
return o;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function hbIntervalMs() {
|
|
334
|
+
let s = Number(process.env.LUANG_HEARTBEAT_SEC);
|
|
335
|
+
if (!s || !Number.isFinite(s) || s < 3) s = 25; // WS default 25s (kontrak: ≤30s)
|
|
336
|
+
return Math.min(Math.floor(s), 60) * 1000;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// agent.command dari server: set_model | pause | resume → command.ack
|
|
340
|
+
function handleAgentCommand(d, ctx) {
|
|
341
|
+
const { ch, rt, execCfg, state, current } = ctx;
|
|
342
|
+
const id = d.command_id || 'cmd';
|
|
343
|
+
const cmd = String(d.command || '').toLowerCase();
|
|
344
|
+
if (cmd === 'set_model') {
|
|
345
|
+
const type = d.provider || rt.cfg.default_provider;
|
|
346
|
+
const model = typeof d.model === 'string' ? d.model.trim() : '';
|
|
347
|
+
if (!type) {
|
|
348
|
+
ch.ack(id, false, 'Provider tidak diketahui & default provider belum di-set — jalankan: luang-cli provider default <type>');
|
|
349
|
+
return;
|
|
150
350
|
}
|
|
351
|
+
if (!model || !/^[\w.:+/-]+$/.test(model)) {
|
|
352
|
+
ch.ack(id, false, `Model tidak valid: ${JSON.stringify(d.model)}`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const cur = (rt.cfg.providers && rt.cfg.providers[type]) || {};
|
|
356
|
+
rt.cfg.providers[type] = {
|
|
357
|
+
...cur,
|
|
358
|
+
provider_type: type,
|
|
359
|
+
display_name: cur.display_name || DISPLAY_NAMES[type] || type,
|
|
360
|
+
model,
|
|
361
|
+
};
|
|
362
|
+
saveConfig(rt.cfg);
|
|
363
|
+
if (!execCfg.type || execCfg.type === type) execCfg.model = model;
|
|
364
|
+
console.log(`🎛 Perintah server: set_model → ${type} = ${model} (tersimpan)`);
|
|
365
|
+
ch.ack(id, true, `Model "${model}" tersimpan untuk provider ${type}`);
|
|
366
|
+
runExecMaybe(ch, state, execCfg, current); // tugas pending mungkin baru bisa jalan
|
|
367
|
+
} else if (cmd === 'pause') {
|
|
368
|
+
ch.paused = true;
|
|
369
|
+
console.log('⏸ Perintah server: PAUSE — eksekusi ditunda (heartbeat tetap).');
|
|
370
|
+
ch.ack(id, true, 'paused');
|
|
371
|
+
} else if (cmd === 'resume') {
|
|
372
|
+
ch.paused = false;
|
|
373
|
+
console.log('▶️ Perintah server: RESUME — eksekusi tugas dilanjutkan.');
|
|
374
|
+
ch.ack(id, true, 'resumed');
|
|
375
|
+
runExecMaybe(ch, state, execCfg, current);
|
|
376
|
+
} else {
|
|
377
|
+
ch.ack(id, false, `Perintah tidak dikenal: ${cmd}`);
|
|
151
378
|
}
|
|
379
|
+
}
|
|
152
380
|
|
|
153
|
-
|
|
154
|
-
const current = { child: null };
|
|
155
|
-
let running = true;
|
|
381
|
+
// ── Mode realtime (WS) ────────────────────────────────────────────────────────
|
|
156
382
|
|
|
157
|
-
|
|
383
|
+
function runAgentRealtime(rt, opts) {
|
|
384
|
+
const { execCfg, state, current, doExec } = opts;
|
|
385
|
+
const wsUrl = wsRealtimeUrl(rt.apiUrl);
|
|
386
|
+
if (!wsUrl) return Promise.resolve('fatal');
|
|
387
|
+
const ws = new wsLib.WsClient(wsUrl, { headers: { 'X-CLI-Key': rt.key } });
|
|
388
|
+
current.ws = ws;
|
|
389
|
+
const consent = makeConsent(rt.cfg);
|
|
390
|
+
const ch = makeChannel('ws', { rt, ws, consent });
|
|
391
|
+
|
|
392
|
+
console.log('🤖 Agent Luang aktif (realtime WebSocket)');
|
|
393
|
+
console.log(` WS : ${wsUrl}`);
|
|
158
394
|
console.log(` API : ${rt.apiUrl}`);
|
|
159
|
-
console.log(`
|
|
160
|
-
console.log(` Eksekusi : ${doExec ? (execCfg.template ? `aktif (${execCfg.type})` : 'diminta tapi nonaktif — default provider belum punya exec_template berisi {task}') : 'nonaktif'}`);
|
|
395
|
+
console.log(` Heartbeat : frame tiap ≤30s (interval ${Math.round(hbIntervalMs() / 1000)}s)`);
|
|
396
|
+
console.log(` Eksekusi : ${doExec ? (execCfg.template ? `aktif (${execCfg.type}${execCfg.model ? ` · model ${execCfg.model}` : ''})` : 'diminta tapi nonaktif — default provider belum punya exec_template berisi {task}') : 'nonaktif'}`);
|
|
397
|
+
console.log(` Telemetry : ${consent.local === true ? 'config ON (butuh flag server ON juga)' : 'OFF (luang-cli telemetry on)'}`);
|
|
161
398
|
console.log(` State : ${STATE_FILE}`);
|
|
162
399
|
console.log(' Hentikan : Ctrl+C');
|
|
163
400
|
console.log('');
|
|
401
|
+
console.log('⏳ Menunggu handshake realtime…');
|
|
164
402
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
let settled = false;
|
|
405
|
+
let established = false;
|
|
406
|
+
let firstSync = true;
|
|
407
|
+
let hbTimer = null;
|
|
408
|
+
|
|
409
|
+
const settle = (v) => {
|
|
410
|
+
if (settled) return;
|
|
411
|
+
settled = true;
|
|
412
|
+
clearTimeout(guard);
|
|
413
|
+
if (hbTimer) {
|
|
414
|
+
clearInterval(hbTimer);
|
|
415
|
+
hbTimer = null;
|
|
416
|
+
}
|
|
417
|
+
if (v !== 'ok') ws.close(); // beres: jangan reconnect setelah fallback
|
|
418
|
+
resolve(v);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const guard = setTimeout(() => {
|
|
422
|
+
if (!established) settle('fallback');
|
|
423
|
+
}, REALTIME_GUARD_MS);
|
|
424
|
+
|
|
425
|
+
ws.on('open', () => {
|
|
168
426
|
try {
|
|
169
|
-
|
|
427
|
+
ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
|
|
170
428
|
} catch (e) {
|
|
171
|
-
|
|
429
|
+
console.error(`⚠️ Gagal kirim hello: ${e.message}`);
|
|
172
430
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
431
|
+
if (established) console.log(`🔌 WS terhubung ulang (${stampNow()}) — hello dikirim ulang`);
|
|
432
|
+
});
|
|
433
|
+
ws.on('reconnecting', ({ delay, reason }) => {
|
|
434
|
+
console.log(`🔁 WS terputus (${trunc(String(reason), 40)}) — mencoba ulang dalam ${(delay / 1000).toFixed(1)}s…`);
|
|
435
|
+
});
|
|
436
|
+
ws.on('error', () => {
|
|
437
|
+
// Gagal sambung sebelum/fallback cukup jelas dari fallback notice; setelah isi, event reconnects sudah dicetak.
|
|
438
|
+
});
|
|
439
|
+
ws.on('fatal', (e) => {
|
|
440
|
+
console.error(`❌ WS ditolak server: ${e.message}`);
|
|
441
|
+
settle('fatal');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
hbTimer = setInterval(() => {
|
|
445
|
+
try {
|
|
446
|
+
ws.send({ type: 'heartbeat', ts: Date.now(), data: {} });
|
|
447
|
+
} catch (e) {
|
|
448
|
+
// ws lib mengantre saat reconnect; biarkan
|
|
449
|
+
}
|
|
450
|
+
}, hbIntervalMs());
|
|
451
|
+
|
|
452
|
+
ws.on('message', (raw) => {
|
|
453
|
+
let msg;
|
|
454
|
+
try {
|
|
455
|
+
msg = JSON.parse(raw);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return; // pesan invalid → abaikan (server yang benar tidak mengirim ini)
|
|
458
|
+
}
|
|
459
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
460
|
+
const d = msg.data && typeof msg.data === 'object' ? msg.data : {};
|
|
461
|
+
const ctx = { ch, rt, execCfg, state, current };
|
|
462
|
+
|
|
463
|
+
if (msg.type === 'hello.ok') {
|
|
464
|
+
consent.server = d.telemetry_enabled === true;
|
|
465
|
+
if (!established) {
|
|
466
|
+
established = true;
|
|
467
|
+
clearTimeout(guard);
|
|
468
|
+
console.log(`🔌 Realtime aktif · key #${d.key_id ?? '-'} · user #${d.user_id ?? '-'}${d.server_time ? ` · server_time ${d.server_time}` : ''}`);
|
|
469
|
+
console.log(' Event live: task.snapshot / task.assigned / task.updated · agent.command');
|
|
470
|
+
if (consent.local === true && consent.server === true) {
|
|
471
|
+
console.log('📈 Telemetry consent ON (lokal ✅ · server ✅) — event run dikirim realtime.');
|
|
472
|
+
} else {
|
|
473
|
+
noticeConsentOnce(consent);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!established) return;
|
|
479
|
+
|
|
480
|
+
if (msg.type === 'task.snapshot') {
|
|
481
|
+
const tasks = c.normalizeTaskList(d);
|
|
482
|
+
let dirty = false;
|
|
483
|
+
const seen = new Set();
|
|
484
|
+
for (const t of tasks) {
|
|
485
|
+
const id = c.taskIdOf(t);
|
|
486
|
+
if (!id) continue;
|
|
487
|
+
seen.add(id);
|
|
488
|
+
if (ingestTaskIntoState(t, state)) {
|
|
489
|
+
dirty = true;
|
|
490
|
+
printTaskLine(t, firstSync ? 'sinkron awal' : 'berubah');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const id of Object.keys(state.tasks)) {
|
|
494
|
+
if (!seen.has(id)) {
|
|
495
|
+
delete state.tasks[id];
|
|
496
|
+
dirty = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
firstSync = false;
|
|
500
|
+
ch.latestTasks = tasks;
|
|
501
|
+
if (dirty) saveState(state);
|
|
502
|
+
runExecMaybe(ch, state, execCfg, current);
|
|
503
|
+
} else if (msg.type === 'task.updated' || msg.type === 'task.assigned') {
|
|
504
|
+
const t = d.task && typeof d.task === 'object' ? d.task : d.id ? d : null;
|
|
505
|
+
if (!t) return;
|
|
506
|
+
if (ingestTaskIntoState(t, state)) {
|
|
507
|
+
printTaskLine(t, msg.type === 'task.assigned' ? 'di-assign' : 'berubah');
|
|
508
|
+
saveState(state);
|
|
509
|
+
}
|
|
510
|
+
const id = c.taskIdOf(t);
|
|
511
|
+
const i = ch.latestTasks.findIndex((x) => c.taskIdOf(x) === id);
|
|
512
|
+
if (i >= 0) ch.latestTasks[i] = t;
|
|
513
|
+
else ch.latestTasks.push(t);
|
|
514
|
+
runExecMaybe(ch, state, execCfg, current);
|
|
515
|
+
} else if (msg.type === 'agent.command') {
|
|
516
|
+
handleAgentCommand(d, ctx);
|
|
517
|
+
} else if (msg.type === 'error') {
|
|
518
|
+
console.error(`⚠️ WS server error: ${d.code || '-'} ${trunc(String(d.message || ''), 120)}`);
|
|
519
|
+
}
|
|
520
|
+
// heartbeat / agent.online / lainnya diabaikan (bukan untuk CLI)
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
ws.connect();
|
|
176
524
|
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── Mode polling (fallback, perilaku lama) ───────────────────────────────────
|
|
528
|
+
|
|
529
|
+
async function runAgentPolling(rt, opts) {
|
|
530
|
+
const { intervalSec, doExec, execCfg, state, current } = opts;
|
|
531
|
+
const ch = makeChannel('http', { rt });
|
|
532
|
+
let running = true;
|
|
533
|
+
|
|
534
|
+
console.log('🤖 Agent Luang aktif');
|
|
535
|
+
console.log(` API : ${rt.apiUrl}`);
|
|
536
|
+
console.log(` Poll : setiap ${intervalSec} detik · heartbeat setiap 30 detik`);
|
|
537
|
+
console.log(' Realtime : nonaktif — polling HTTP (WS gagal/dimatikan)');
|
|
538
|
+
console.log(' Telemetry : tidak dikirim (mode polling tanpa kanal realtime)');
|
|
539
|
+
console.log(` Eksekusi : ${doExec ? (execCfg.template ? `aktif (${execCfg.type})` : 'diminta tapi nonaktif — default provider belum punya exec_template berisi {task}') : 'nonaktif'}`);
|
|
540
|
+
console.log(` State : ${STATE_FILE}`);
|
|
541
|
+
console.log(' Hentikan : Ctrl+C');
|
|
542
|
+
console.log('');
|
|
177
543
|
|
|
178
544
|
const heartbeat = async (label) => {
|
|
179
545
|
try {
|
|
180
546
|
await apiRequest(rt, 'POST', '/api/v1/agent/heartbeat', {
|
|
181
|
-
body: { providers:
|
|
547
|
+
body: { providers: heartbeatProviders(collectProviders(rt.cfg)) },
|
|
182
548
|
});
|
|
183
549
|
console.log(`📡 ${label} terkirim`);
|
|
184
550
|
} catch (e) {
|
|
@@ -186,24 +552,26 @@ async function cmdAgent(rt, args) {
|
|
|
186
552
|
}
|
|
187
553
|
};
|
|
188
554
|
|
|
189
|
-
|
|
555
|
+
let hbTimer = setInterval(() => {
|
|
190
556
|
if (running) heartbeat('Heartbeat');
|
|
191
|
-
},
|
|
557
|
+
}, hbIntervalSec() * 1000);
|
|
192
558
|
|
|
193
559
|
let first = true;
|
|
194
|
-
|
|
560
|
+
let runningLoop = true;
|
|
561
|
+
while (runningLoop) {
|
|
195
562
|
let tasks;
|
|
196
563
|
try {
|
|
197
564
|
const res = await apiRequest(rt, 'GET', '/api/v1/agent/tasks');
|
|
198
565
|
tasks = c.normalizeTaskList(res);
|
|
199
566
|
} catch (e) {
|
|
200
567
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
|
|
201
|
-
clearInterval(
|
|
568
|
+
clearInterval(hbTimer);
|
|
569
|
+
runningLoop = false;
|
|
202
570
|
console.error(`❌ Key ditolak server (HTTP ${e.status}): ${e.message}`);
|
|
203
571
|
console.error(' Cek: luang-cli whoami · login ulang: luang-cli login');
|
|
204
572
|
return 1;
|
|
205
573
|
}
|
|
206
|
-
console.
|
|
574
|
+
console.log(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
|
|
207
575
|
await sleep(intervalSec * 1000);
|
|
208
576
|
continue;
|
|
209
577
|
}
|
|
@@ -214,21 +582,11 @@ async function cmdAgent(rt, args) {
|
|
|
214
582
|
const id = c.taskIdOf(t);
|
|
215
583
|
if (!id) continue;
|
|
216
584
|
seen.add(id);
|
|
217
|
-
|
|
218
|
-
const fp = taskIdFingerprint(t);
|
|
219
|
-
const prev = state.tasks[id];
|
|
220
|
-
// berubah bila tidak ada cache, atau fingerprint (status/agent_status/note/waktu) berbeda
|
|
221
|
-
const changed =
|
|
222
|
-
!prev ||
|
|
223
|
-
prev.fp !== fp ||
|
|
224
|
-
(!prev.fp && prev.updated_at !== cur);
|
|
225
|
-
if (changed) {
|
|
226
|
-
state.tasks[id] = { updated_at: cur, fp, title: t.title, status: t.status };
|
|
585
|
+
if (ingestTaskIntoState(t, state)) {
|
|
227
586
|
dirty = true;
|
|
228
587
|
printTaskLine(t, first ? 'baru dipantau' : 'berubah');
|
|
229
588
|
}
|
|
230
589
|
}
|
|
231
|
-
// bersihkan cache untuk tugas yang tak lagi dikirim server
|
|
232
590
|
for (const id of Object.keys(state.tasks)) {
|
|
233
591
|
if (!seen.has(id)) {
|
|
234
592
|
delete state.tasks[id];
|
|
@@ -237,18 +595,82 @@ async function cmdAgent(rt, args) {
|
|
|
237
595
|
}
|
|
238
596
|
first = false;
|
|
239
597
|
if (dirty) saveState(state);
|
|
598
|
+
ch.latestTasks = tasks;
|
|
240
599
|
|
|
241
600
|
if (doExec) {
|
|
242
|
-
const acted = await tryExecTask(
|
|
601
|
+
const acted = await tryExecTask(ch, tasks, state, execCfg, current);
|
|
243
602
|
if (acted) saveState(state);
|
|
244
603
|
}
|
|
245
604
|
|
|
246
605
|
await sleep(intervalSec * 1000);
|
|
247
606
|
}
|
|
248
|
-
clearInterval(
|
|
607
|
+
clearInterval(hbTimer);
|
|
249
608
|
return 0;
|
|
250
609
|
}
|
|
251
610
|
|
|
611
|
+
function hbIntervalSec() {
|
|
612
|
+
let s = Number(process.env.LUANG_HEARTBEAT_SEC);
|
|
613
|
+
if (!s || !Number.isFinite(s) || s < 3) s = 30; // HTTP polling: 30s (perilaku lama)
|
|
614
|
+
return Math.min(Math.floor(s), 3600);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function cmdAgent(rt, args) {
|
|
618
|
+
if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
|
|
619
|
+
const flags = args.flags;
|
|
620
|
+
let intervalSec = Number(flags.interval);
|
|
621
|
+
if (!intervalSec || intervalSec < 1) intervalSec = 5;
|
|
622
|
+
if (intervalSec > 3600) intervalSec = 3600;
|
|
623
|
+
const doExec = flags.exec === true;
|
|
624
|
+
|
|
625
|
+
const execCfg = { type: rt.cfg.default_provider, template: null, model: null };
|
|
626
|
+
if (doExec) {
|
|
627
|
+
const p = collectProviders(rt.cfg).find((x) => x.provider_type === rt.cfg.default_provider);
|
|
628
|
+
if (p && p.exec_template && p.exec_template.includes('{task}')) execCfg.template = p.exec_template;
|
|
629
|
+
if (p && p.model) execCfg.model = p.model;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const state = loadState();
|
|
633
|
+
const current = { child: null, ws: null };
|
|
634
|
+
|
|
635
|
+
process.on('SIGINT', () => {
|
|
636
|
+
if (current.child) {
|
|
637
|
+
try {
|
|
638
|
+
current.child.kill('SIGINT');
|
|
639
|
+
} catch (e) {
|
|
640
|
+
// abaikan
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (current.ws) {
|
|
644
|
+
try {
|
|
645
|
+
current.ws.close();
|
|
646
|
+
} catch (e) {
|
|
647
|
+
// abaikan
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
console.log('\n👋 Agent dihentikan.');
|
|
651
|
+
process.exit(0);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// 1) Realtime WebSocket — enhancement, bukan syarat (kontrak §5).
|
|
655
|
+
if (canRealtime(rt)) {
|
|
656
|
+
const mode = await runAgentRealtime(rt, { doExec, execCfg, state, current });
|
|
657
|
+
if (mode === 'ok') return 0; // sampai Ctrl+C (process.exit pada handler)
|
|
658
|
+
console.log('');
|
|
659
|
+
if (mode === 'fatal') {
|
|
660
|
+
console.log('❌ Server menolak/menyediakan WS — beralih ke fallback polling HTTP.');
|
|
661
|
+
} else {
|
|
662
|
+
console.log('⚠️ WS gagal terhubung dalam batas waktu — beralih ke fallback polling HTTP.');
|
|
663
|
+
}
|
|
664
|
+
console.log('');
|
|
665
|
+
} else if (!wsLib) {
|
|
666
|
+
console.log('⚠️ lib/ws tidak tersedia di Node ini — beralih ke fallback polling HTTP.');
|
|
667
|
+
console.log('');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 2) Fallback polling HTTP klasik.
|
|
671
|
+
return runAgentPolling(rt, { intervalSec, doExec, execCfg, state, current });
|
|
672
|
+
}
|
|
673
|
+
|
|
252
674
|
// ── task list / create / status ──────────────────────────────────────────────
|
|
253
675
|
|
|
254
676
|
async function cmdTaskList(rt, args) {
|
|
@@ -382,15 +804,18 @@ async function cmdStatus(rt) {
|
|
|
382
804
|
console.log(' ➡️ Mulai: luang-cli login');
|
|
383
805
|
return 0;
|
|
384
806
|
}
|
|
807
|
+
let d = null;
|
|
385
808
|
try {
|
|
386
809
|
const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
|
|
387
|
-
|
|
810
|
+
d = (me && me.data) || {};
|
|
388
811
|
console.log(` Sesi : ${d.name || '-'} (${d.key_id || 'key-?'}) · status ${d.status || '-'}`);
|
|
389
812
|
console.log(` Tenant: ${d.tenant_slug || d.tenant_id || '-'}`);
|
|
390
813
|
console.log(` Izin : update-tugas ${d.can_update_tasks ? '✅' : '❌'} · buat-tugas ${d.can_create_tasks ? '✅' : '❌'}`);
|
|
814
|
+
console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, d.telemetry_enabled === true)} (ubah lokal: telemetry on|off)`);
|
|
391
815
|
console.log(` Provider server: ${c.providersLabel(d.providers)}`);
|
|
392
816
|
} catch (e) {
|
|
393
817
|
console.log(` ⚠️ /me gagal: ${e.message}`);
|
|
818
|
+
console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, null)}`);
|
|
394
819
|
}
|
|
395
820
|
const list = collectProviders(rt.cfg);
|
|
396
821
|
console.log(` Default provider: ${rt.cfg.default_provider || '(belum di-set)'}`);
|
|
@@ -421,4 +846,4 @@ async function cmdMcpConfig(rt) {
|
|
|
421
846
|
return 0;
|
|
422
847
|
}
|
|
423
848
|
|
|
424
|
-
module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild };
|
|
849
|
+
module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild, wsRealtimeUrl, canRealtime, providersForHello, hbIntervalMs, makeChannel, handleAgentCommand, ingestTaskIntoState };
|