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/lib/agent.js CHANGED
@@ -1,9 +1,19 @@
1
1
  'use strict';
2
2
 
3
- // lib/agent.js — daemon agent, task list/create/status, status ringkas,
4
- // dan mcp-config. Bagian lanjutan dari lib/commands.js.
5
-
6
- const { exec } = require('child_process');
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 += `agent=${t.agent_status}`;
45
- if (t.agent_progress_note) tail += (tail ? ' ' : '') + `note="${t.agent_progress_note}"`;
46
- console.log(`[${stampNow()}] ${code} ${trunc(t.title, 40)} ${t.status || '-'} ${tail}${tag ? ` ${trunc(tag, 20)}` : ''}`);
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 (tpl.includes('"{task}"')) {
88
+ if (t.includes('"{task}"')) {
53
89
  q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
54
- } else if (tpl.includes("'{task}'")) {
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 tpl.replaceAll('{task}', q);
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
- child = exec(cmd, { maxBuffer: 8 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, windowsHide: true });
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
- async function tryExecTask(rt, tasks, state, execCfg, current) {
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
- const cmdStr = buildExecCommand(execCfg.template, taskText);
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
- // Spesifikasi: mulai → body {status:"in_progress"} saja.
111
- try {
112
- await apiRequest(rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(code)}/status`, {
113
- body: { status: 'in_progress' },
114
- });
115
- } catch (e) {
116
- console.error(`⚠️ Gagal set in_progress: ${e.message}`);
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 selesai"};
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
- await apiRequest(rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(code)}/status`, {
126
- body: ok
127
- ? { status: 'done', agent_status: 'selesai', progress_note: 'exec selesai' }
128
- : { status: 'review', agent_status: 'gagal' },
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
- console.error(`⚠️ Gagal update status tugas: ${e.message}`);
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
- async function cmdAgent(rt, args) {
138
- if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
139
- const flags = args.flags;
140
- let intervalSec = Number(flags.interval);
141
- if (!intervalSec || intervalSec < 1) intervalSec = 5;
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
- const execCfg = { type: rt.cfg.default_provider, template: null };
146
- if (doExec) {
147
- const p = collectProviders(rt.cfg).find((x) => x.provider_type === rt.cfg.default_provider);
148
- if (p && p.exec_template && p.exec_template.includes('{task}')) {
149
- execCfg.template = p.exec_template;
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
- const state = loadState();
154
- const current = { child: null };
155
- let running = true;
381
+ // ── Mode realtime (WS) ────────────────────────────────────────────────────────
156
382
 
157
- console.log('🤖 Agent Luang aktif');
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(` Poll : setiap ${intervalSec} detik · heartbeat setiap 30 detik`);
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
- process.on('SIGINT', () => {
166
- running = false;
167
- if (current.child) {
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
- current.child.kill('SIGINT');
427
+ ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
170
428
  } catch (e) {
171
- // abaikan
429
+ console.error(`⚠️ Gagal kirim hello: ${e.message}`);
172
430
  }
173
- }
174
- console.log('\n👋 Agent dihentikan.');
175
- process.exit(0);
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: core.heartbeatProviders(collectProviders(rt.cfg)) },
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
- const hbInterval = setInterval(() => {
555
+ let hbTimer = setInterval(() => {
190
556
  if (running) heartbeat('Heartbeat');
191
- }, 30000);
557
+ }, hbIntervalSec() * 1000);
192
558
 
193
559
  let first = true;
194
- while (running) {
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(hbInterval);
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.error(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
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
- const cur = t.agent_updated_at || t.updated_at || null;
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(rt, tasks, state, execCfg, current);
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(hbInterval);
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
- const d = (me && me.data) || {};
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 };