luang-cli 0.2.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 +78 -21
- package/bin/luang-cli.js +48 -18
- package/lib/agent.js +497 -73
- 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() {
|
|
@@ -47,17 +63,36 @@ function printTaskLine(t, tag) {
|
|
|
47
63
|
console.log(`${code} ${trunc(t.title, 36)} ${t.status || '-'} ${tail}${stamp}`.replace(/\s+$/, ''));
|
|
48
64
|
}
|
|
49
65
|
|
|
50
|
-
|
|
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;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildExecCommand(tpl, taskText, rawModel) {
|
|
51
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
|
+
}
|
|
52
87
|
let q;
|
|
53
|
-
if (
|
|
88
|
+
if (t.includes('"{task}"')) {
|
|
54
89
|
q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
|
|
55
|
-
} else if (
|
|
90
|
+
} else if (t.includes("'{task}'")) {
|
|
56
91
|
q = taskText.replace(/'/g, `'\\''`); // escape untuk kutip tunggal
|
|
57
92
|
} else {
|
|
58
93
|
q = taskText.replace(/(["`\\$\n])/g, ' '); // {task} tanpa kutip → rapikan
|
|
59
94
|
}
|
|
60
|
-
return
|
|
95
|
+
return t.replaceAll('{task}', q);
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
function runExecChild(cmd, setChild) {
|
|
@@ -65,13 +100,18 @@ function runExecChild(cmd, setChild) {
|
|
|
65
100
|
return new Promise((resolve) => {
|
|
66
101
|
let child = null;
|
|
67
102
|
try {
|
|
68
|
-
|
|
103
|
+
// stdio inherit → output provider (mis. "opencode run") tampil live di terminal.
|
|
104
|
+
child = spawn(cmd, [], { shell: true, stdio: 'inherit', windowsHide: true });
|
|
69
105
|
} catch (e) {
|
|
70
106
|
resolve(1);
|
|
71
107
|
return;
|
|
72
108
|
}
|
|
73
109
|
if (setChild) setChild(child);
|
|
110
|
+
const killer = setTimeout(() => {
|
|
111
|
+
try { child.kill('SIGKILL'); } catch (e) { /* abaikan */ }
|
|
112
|
+
}, EXEC_TIMEOUT_MS);
|
|
74
113
|
const fin = (code) => {
|
|
114
|
+
clearTimeout(killer);
|
|
75
115
|
if (setChild) setChild(null);
|
|
76
116
|
resolve(typeof code === 'number' ? code : 1);
|
|
77
117
|
};
|
|
@@ -80,12 +120,119 @@ function runExecChild(cmd, setChild) {
|
|
|
80
120
|
});
|
|
81
121
|
}
|
|
82
122
|
|
|
83
|
-
|
|
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
|
|
84
219
|
const cand = tasks.find((t) => c.statusInputNorm(t.status) === 'todo' && !state.executed[c.taskIdOf(t)]);
|
|
85
220
|
if (!cand) return false;
|
|
86
221
|
const code = c.taskIdOf(cand);
|
|
87
222
|
const taskText = `${cand.title || ''}${cand.description ? ' — ' + cand.description : ''}`;
|
|
88
|
-
|
|
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);
|
|
89
236
|
if (!cmdStr) {
|
|
90
237
|
// exec_template kosong / tanpa {task} → hanya notifikasi satu baris (spec)
|
|
91
238
|
state.executed[code] = true; // tandai agar tidak nag lagi
|
|
@@ -108,78 +255,296 @@ async function tryExecTask(rt, tasks, state, execCfg, current) {
|
|
|
108
255
|
saveState(state);
|
|
109
256
|
return true;
|
|
110
257
|
}
|
|
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();
|
|
111
262
|
// Spesifikasi: sebelum eksekusi, tandai in_progress dengan agent_status "menjalankan".
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
body: { status: 'in_progress', agent_status: 'menjalankan' },
|
|
115
|
-
});
|
|
116
|
-
} catch (e) {
|
|
117
|
-
console.error(`⚠️ Gagal set in_progress: ${e.message}`);
|
|
118
|
-
}
|
|
263
|
+
ch.sendStatus(code, { status: 'in_progress', agent_status: 'menjalankan' }, 'set in_progress');
|
|
264
|
+
ch.telemetry({ ...baseTel, status: 'started', ...toks });
|
|
119
265
|
const exitCode = await runExecChild(cmdStr, (child) => {
|
|
120
266
|
current.child = child;
|
|
121
267
|
});
|
|
122
268
|
const ok = exitCode === 0;
|
|
123
269
|
// Spesifikasi: exit 0 → {status:"done", agent_status:"selesai", progress_note:"exec exit 0"};
|
|
124
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) {
|
|
125
303
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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();
|
|
131
312
|
} catch (e) {
|
|
132
|
-
|
|
313
|
+
return null;
|
|
133
314
|
}
|
|
134
|
-
console.log(ok ? `✅ ${code} selesai (exit 0)` : `⚠️ ${code} gagal (exit ${exitCode}) → review`);
|
|
135
|
-
return true;
|
|
136
315
|
}
|
|
137
316
|
|
|
138
|
-
|
|
139
|
-
if (!
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (intervalSec > 3600) intervalSec = 3600;
|
|
144
|
-
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
|
+
}
|
|
145
322
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
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}`);
|
|
152
378
|
}
|
|
379
|
+
}
|
|
153
380
|
|
|
154
|
-
|
|
155
|
-
const current = { child: null };
|
|
156
|
-
let running = true;
|
|
381
|
+
// ── Mode realtime (WS) ────────────────────────────────────────────────────────
|
|
157
382
|
|
|
158
|
-
|
|
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}`);
|
|
159
394
|
console.log(` API : ${rt.apiUrl}`);
|
|
160
|
-
console.log(`
|
|
161
|
-
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)'}`);
|
|
162
398
|
console.log(` State : ${STATE_FILE}`);
|
|
163
399
|
console.log(' Hentikan : Ctrl+C');
|
|
164
400
|
console.log('');
|
|
401
|
+
console.log('⏳ Menunggu handshake realtime…');
|
|
165
402
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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', () => {
|
|
169
426
|
try {
|
|
170
|
-
|
|
427
|
+
ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
|
|
171
428
|
} catch (e) {
|
|
172
|
-
|
|
429
|
+
console.error(`⚠️ Gagal kirim hello: ${e.message}`);
|
|
173
430
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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();
|
|
177
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('');
|
|
178
543
|
|
|
179
544
|
const heartbeat = async (label) => {
|
|
180
545
|
try {
|
|
181
546
|
await apiRequest(rt, 'POST', '/api/v1/agent/heartbeat', {
|
|
182
|
-
body: { providers:
|
|
547
|
+
body: { providers: heartbeatProviders(collectProviders(rt.cfg)) },
|
|
183
548
|
});
|
|
184
549
|
console.log(`📡 ${label} terkirim`);
|
|
185
550
|
} catch (e) {
|
|
@@ -187,24 +552,26 @@ async function cmdAgent(rt, args) {
|
|
|
187
552
|
}
|
|
188
553
|
};
|
|
189
554
|
|
|
190
|
-
|
|
555
|
+
let hbTimer = setInterval(() => {
|
|
191
556
|
if (running) heartbeat('Heartbeat');
|
|
192
|
-
},
|
|
557
|
+
}, hbIntervalSec() * 1000);
|
|
193
558
|
|
|
194
559
|
let first = true;
|
|
195
|
-
|
|
560
|
+
let runningLoop = true;
|
|
561
|
+
while (runningLoop) {
|
|
196
562
|
let tasks;
|
|
197
563
|
try {
|
|
198
564
|
const res = await apiRequest(rt, 'GET', '/api/v1/agent/tasks');
|
|
199
565
|
tasks = c.normalizeTaskList(res);
|
|
200
566
|
} catch (e) {
|
|
201
567
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
|
|
202
|
-
clearInterval(
|
|
568
|
+
clearInterval(hbTimer);
|
|
569
|
+
runningLoop = false;
|
|
203
570
|
console.error(`❌ Key ditolak server (HTTP ${e.status}): ${e.message}`);
|
|
204
571
|
console.error(' Cek: luang-cli whoami · login ulang: luang-cli login');
|
|
205
572
|
return 1;
|
|
206
573
|
}
|
|
207
|
-
console.
|
|
574
|
+
console.log(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
|
|
208
575
|
await sleep(intervalSec * 1000);
|
|
209
576
|
continue;
|
|
210
577
|
}
|
|
@@ -215,21 +582,11 @@ async function cmdAgent(rt, args) {
|
|
|
215
582
|
const id = c.taskIdOf(t);
|
|
216
583
|
if (!id) continue;
|
|
217
584
|
seen.add(id);
|
|
218
|
-
|
|
219
|
-
const fp = taskIdFingerprint(t);
|
|
220
|
-
const prev = state.tasks[id];
|
|
221
|
-
// berubah bila tidak ada cache, atau fingerprint (status/agent_status/note/waktu) berbeda
|
|
222
|
-
const changed =
|
|
223
|
-
!prev ||
|
|
224
|
-
prev.fp !== fp ||
|
|
225
|
-
(!prev.fp && prev.updated_at !== cur);
|
|
226
|
-
if (changed) {
|
|
227
|
-
state.tasks[id] = { updated_at: cur, fp, title: t.title, status: t.status };
|
|
585
|
+
if (ingestTaskIntoState(t, state)) {
|
|
228
586
|
dirty = true;
|
|
229
587
|
printTaskLine(t, first ? 'baru dipantau' : 'berubah');
|
|
230
588
|
}
|
|
231
589
|
}
|
|
232
|
-
// bersihkan cache untuk tugas yang tak lagi dikirim server
|
|
233
590
|
for (const id of Object.keys(state.tasks)) {
|
|
234
591
|
if (!seen.has(id)) {
|
|
235
592
|
delete state.tasks[id];
|
|
@@ -238,18 +595,82 @@ async function cmdAgent(rt, args) {
|
|
|
238
595
|
}
|
|
239
596
|
first = false;
|
|
240
597
|
if (dirty) saveState(state);
|
|
598
|
+
ch.latestTasks = tasks;
|
|
241
599
|
|
|
242
600
|
if (doExec) {
|
|
243
|
-
const acted = await tryExecTask(
|
|
601
|
+
const acted = await tryExecTask(ch, tasks, state, execCfg, current);
|
|
244
602
|
if (acted) saveState(state);
|
|
245
603
|
}
|
|
246
604
|
|
|
247
605
|
await sleep(intervalSec * 1000);
|
|
248
606
|
}
|
|
249
|
-
clearInterval(
|
|
607
|
+
clearInterval(hbTimer);
|
|
250
608
|
return 0;
|
|
251
609
|
}
|
|
252
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
|
+
|
|
253
674
|
// ── task list / create / status ──────────────────────────────────────────────
|
|
254
675
|
|
|
255
676
|
async function cmdTaskList(rt, args) {
|
|
@@ -383,15 +804,18 @@ async function cmdStatus(rt) {
|
|
|
383
804
|
console.log(' ➡️ Mulai: luang-cli login');
|
|
384
805
|
return 0;
|
|
385
806
|
}
|
|
807
|
+
let d = null;
|
|
386
808
|
try {
|
|
387
809
|
const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
|
|
388
|
-
|
|
810
|
+
d = (me && me.data) || {};
|
|
389
811
|
console.log(` Sesi : ${d.name || '-'} (${d.key_id || 'key-?'}) · status ${d.status || '-'}`);
|
|
390
812
|
console.log(` Tenant: ${d.tenant_slug || d.tenant_id || '-'}`);
|
|
391
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)`);
|
|
392
815
|
console.log(` Provider server: ${c.providersLabel(d.providers)}`);
|
|
393
816
|
} catch (e) {
|
|
394
817
|
console.log(` ⚠️ /me gagal: ${e.message}`);
|
|
818
|
+
console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, null)}`);
|
|
395
819
|
}
|
|
396
820
|
const list = collectProviders(rt.cfg);
|
|
397
821
|
console.log(` Default provider: ${rt.cfg.default_provider || '(belum di-set)'}`);
|
|
@@ -422,4 +846,4 @@ async function cmdMcpConfig(rt) {
|
|
|
422
846
|
return 0;
|
|
423
847
|
}
|
|
424
848
|
|
|
425
|
-
module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild };
|
|
849
|
+
module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild, wsRealtimeUrl, canRealtime, providersForHello, hbIntervalMs, makeChannel, handleAgentCommand, ingestTaskIntoState };
|