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/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() {
@@ -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
- function buildExecCommand(tpl, taskText) {
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 (tpl.includes('"{task}"')) {
88
+ if (t.includes('"{task}"')) {
54
89
  q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
55
- } else if (tpl.includes("'{task}'")) {
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 tpl.replaceAll('{task}', q);
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
- 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 });
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
- 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
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
- 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);
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
- try {
113
- await apiRequest(rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(code)}/status`, {
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
- await apiRequest(rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(code)}/status`, {
127
- body: ok
128
- ? { status: 'done', agent_status: 'selesai', progress_note: 'exec exit 0' }
129
- : { status: 'review', agent_status: 'gagal', progress_note: `exit code ${exitCode}` },
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
- console.error(`⚠️ Gagal update status tugas: ${e.message}`);
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
- async function cmdAgent(rt, args) {
139
- if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
140
- const flags = args.flags;
141
- let intervalSec = Number(flags.interval);
142
- if (!intervalSec || intervalSec < 1) intervalSec = 5;
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
- const execCfg = { type: rt.cfg.default_provider, template: null };
147
- if (doExec) {
148
- const p = collectProviders(rt.cfg).find((x) => x.provider_type === rt.cfg.default_provider);
149
- if (p && p.exec_template && p.exec_template.includes('{task}')) {
150
- 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;
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
- const state = loadState();
155
- const current = { child: null };
156
- let running = true;
381
+ // ── Mode realtime (WS) ────────────────────────────────────────────────────────
157
382
 
158
- 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}`);
159
394
  console.log(` API : ${rt.apiUrl}`);
160
- console.log(` Poll : setiap ${intervalSec} detik · heartbeat setiap 30 detik`);
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
- process.on('SIGINT', () => {
167
- running = false;
168
- 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', () => {
169
426
  try {
170
- current.child.kill('SIGINT');
427
+ ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
171
428
  } catch (e) {
172
- // abaikan
429
+ console.error(`⚠️ Gagal kirim hello: ${e.message}`);
173
430
  }
174
- }
175
- console.log('\n👋 Agent dihentikan.');
176
- 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();
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: core.heartbeatProviders(collectProviders(rt.cfg)) },
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
- const hbInterval = setInterval(() => {
555
+ let hbTimer = setInterval(() => {
191
556
  if (running) heartbeat('Heartbeat');
192
- }, 30000);
557
+ }, hbIntervalSec() * 1000);
193
558
 
194
559
  let first = true;
195
- while (running) {
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(hbInterval);
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.error(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
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
- const cur = t.agent_updated_at || t.updated_at || null;
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(rt, tasks, state, execCfg, current);
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(hbInterval);
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
- const d = (me && me.data) || {};
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 };