luang-cli 0.2.0 → 0.3.2

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,21 @@
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.
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.
5
6
 
6
- const { exec } = require('child_process');
7
+ const { spawn } = require('child_process');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const pkg = require('../package.json');
11
+
12
+ // lib/ws bisa gagal dimuat di Node yang sangat tua → daemon tetap jalan via polling.
13
+ let wsLib = null;
14
+ try {
15
+ wsLib = require('./ws');
16
+ } catch (e) {
17
+ wsLib = null;
18
+ }
7
19
 
8
20
  const core = require('./core');
9
21
  const c = require('./commands');
@@ -12,10 +24,15 @@ const {
12
24
  apiRequest,
13
25
  loadState,
14
26
  saveState,
27
+ saveConfig,
15
28
  collectProviders,
29
+ heartbeatProviders,
30
+ resolveWorkspace,
16
31
  DEFAULT_TEMPLATES,
32
+ DISPLAY_NAMES,
17
33
  STATE_FILE,
18
34
  CONFIG_DIR,
35
+ modelsForProvider,
19
36
  fmtWhen,
20
37
  keyPrefix,
21
38
  trunc,
@@ -25,6 +42,14 @@ const {
25
42
 
26
43
  const STATUS_LABELS = { todo: 'To-Do', in_progress: 'In Progress', review: 'Review', done: 'Done' };
27
44
 
45
+ const WS_PATH = '/api/v1/agent/ws'; // kontrak: docs/agent-realtime-ws.md §1
46
+ const REALTIME_GUARD_MS = 12000; // beri waktu beberapa percobaan WS sebelum fallback
47
+
48
+ // File daemon background (lihat `luang-cli agent start|stop|status|logs`).
49
+ const BIN = path.join(__dirname, '..', 'bin', 'luang-cli.js');
50
+ const PID_FILE = path.join(CONFIG_DIR, 'agent.pid'); // JSON {pid, started_at, auto, mode}
51
+ const LOG_FILE = path.join(CONFIG_DIR, 'agent.log');
52
+
28
53
  // ── agent (daemon) ───────────────────────────────────────────────────────────
29
54
 
30
55
  function stampNow() {
@@ -33,6 +58,80 @@ function stampNow() {
33
58
  return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
34
59
  }
35
60
 
61
+ // ── util file daemon background ──────────────────────────────────────────────
62
+
63
+ function tildePath(p) {
64
+ const home = process.env.HOME;
65
+ return home ? String(p).replace(home, '~') : String(p);
66
+ }
67
+
68
+ // hidup? (process.kill(pid, 0) — EPERM = hidup tapi bukan milik kita)
69
+ function isPidAlive(pid) {
70
+ const n = Number(pid);
71
+ if (!Number.isInteger(n) || n <= 0) return false;
72
+ try {
73
+ process.kill(n, 0);
74
+ return true;
75
+ } catch (e) {
76
+ return !!(e && e.code === 'EPERM');
77
+ }
78
+ }
79
+
80
+ function readPidInfo() {
81
+ try {
82
+ const raw = JSON.parse(fs.readFileSync(PID_FILE, 'utf8'));
83
+ const pid = Number(raw && raw.pid);
84
+ if (Number.isInteger(pid) && pid > 0) {
85
+ return {
86
+ pid,
87
+ started_at: typeof raw.started_at === 'string' ? raw.started_at : null,
88
+ auto: raw.auto === true,
89
+ mode: typeof raw.mode === 'string' ? raw.mode : raw.auto === true ? 'auto' : 'manual',
90
+ };
91
+ }
92
+ } catch (e) {
93
+ // tidak ada / rusak → anggap tidak ada
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function writePidInfo(info) {
99
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
100
+ fs.writeFileSync(PID_FILE, JSON.stringify(info, null, 2) + '\n');
101
+ }
102
+
103
+ function removePidInfo() {
104
+ try {
105
+ fs.unlinkSync(PID_FILE);
106
+ } catch (e) {
107
+ // sudah tidak ada → abaikan
108
+ }
109
+ }
110
+
111
+ // n baris terakhir log (null bila file belum ada)
112
+ function tailLog(n) {
113
+ try {
114
+ const txt = fs.readFileSync(LOG_FILE, 'utf8');
115
+ const lines = txt.split(/\r?\n/);
116
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
117
+ return lines.slice(-Math.max(1, n));
118
+ } catch (e) {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function fmtUptime(ms) {
124
+ const total = Math.max(0, Math.floor(ms / 1000));
125
+ const d = Math.floor(total / 86400);
126
+ const h = Math.floor((total % 86400) / 3600);
127
+ const m = Math.floor((total % 3600) / 60);
128
+ const s = total % 60;
129
+ if (d > 0) return `${d}d ${h}j`;
130
+ if (h > 0) return `${h}j ${m}m`;
131
+ if (m > 0) return `${m}m ${s}s`;
132
+ return `${s}s`;
133
+ }
134
+
36
135
  // Deteksi perubahan lebih luas dari updated_at saja: status, agent_status, note & waktu.
37
136
  function taskIdFingerprint(t) {
38
137
  return JSON.stringify([t.status || '', t.agent_status || '', t.agent_progress_note || '', t.agent_updated_at || '', t.updated_at || '', t.title || '']);
@@ -47,31 +146,56 @@ function printTaskLine(t, tag) {
47
146
  console.log(`${code} ${trunc(t.title, 36)} ${t.status || '-'} ${tail}${stamp}`.replace(/\s+$/, ''));
48
147
  }
49
148
 
50
- function buildExecCommand(tpl, taskText) {
149
+ // Masukkan satu tugas ke cache state; true bila berubah (sesuai fingerprint).
150
+ function ingestTaskIntoState(t, state) {
151
+ const id = c.taskIdOf(t);
152
+ if (!id) return false;
153
+ const fp = taskIdFingerprint(t);
154
+ const cur = t.agent_updated_at || t.updated_at || null;
155
+ const prev = state.tasks[id];
156
+ const changed = !prev || prev.fp !== fp || (!prev.fp && prev.updated_at !== cur);
157
+ if (changed) state.tasks[id] = { updated_at: cur, fp, title: t.title, status: t.status };
158
+ return changed;
159
+ }
160
+
161
+ function buildExecCommand(tpl, taskText, rawModel) {
51
162
  if (!tpl || !tpl.includes('{task}')) return null;
163
+ let t = tpl;
164
+ if (t.includes('{model}')) {
165
+ // {model} diisi dari model provider (config/agent.command), fallback env LUANG_MODEL.
166
+ const model = String(rawModel || process.env.LUANG_MODEL || '').trim();
167
+ if (!model || !/^[\w.:+/-]+$/.test(model)) return null; // tanpa model → pemanggil menampilkan notifikasi
168
+ t = t.split('{model}').join(model);
169
+ }
52
170
  let q;
53
- if (tpl.includes('"{task}"')) {
171
+ if (t.includes('"{task}"')) {
54
172
  q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
55
- } else if (tpl.includes("'{task}'")) {
173
+ } else if (t.includes("'{task}'")) {
56
174
  q = taskText.replace(/'/g, `'\\''`); // escape untuk kutip tunggal
57
175
  } else {
58
176
  q = taskText.replace(/(["`\\$\n])/g, ' '); // {task} tanpa kutip → rapikan
59
177
  }
60
- return tpl.replaceAll('{task}', q);
178
+ return t.replaceAll('{task}', q);
61
179
  }
62
180
 
63
- function runExecChild(cmd, setChild) {
181
+ function runExecChild(cmd, setChild, cwd) {
64
182
  const EXEC_TIMEOUT_MS = 15 * 60 * 1000; // exec dibatasi 15 menit agar daemon tidak menggantung
65
183
  return new Promise((resolve) => {
66
184
  let child = null;
67
185
  try {
68
- child = exec(cmd, { maxBuffer: 8 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, windowsHide: true });
186
+ // stdio inherit → output provider (mis. "opencode run") tampil live di terminal.
187
+ // cwd = workspace folder (dashboard/CLI) bila valid, kalau tidak = cwd proses.
188
+ child = spawn(cmd, [], { shell: true, stdio: 'inherit', windowsHide: true, cwd: cwd || process.cwd() });
69
189
  } catch (e) {
70
190
  resolve(1);
71
191
  return;
72
192
  }
73
193
  if (setChild) setChild(child);
194
+ const killer = setTimeout(() => {
195
+ try { child.kill('SIGKILL'); } catch (e) { /* abaikan */ }
196
+ }, EXEC_TIMEOUT_MS);
74
197
  const fin = (code) => {
198
+ clearTimeout(killer);
75
199
  if (setChild) setChild(null);
76
200
  resolve(typeof code === 'number' ? code : 1);
77
201
  };
@@ -80,14 +204,135 @@ function runExecChild(cmd, setChild) {
80
204
  });
81
205
  }
82
206
 
83
- async function tryExecTask(rt, tasks, state, execCfg, current) {
207
+ // ── Kanal transport ────────────────────────────────────────────────────────────
208
+ // 'ws' → kirim via frame WS (task.status / command.ack / telemetry.run)
209
+ // 'http' → perilaku lama: task status lewat POST /agent/tasks/:ref/status
210
+
211
+ function makeConsent(cfg) {
212
+ return {
213
+ local: cfg.telemetry === true, // config.telemetry — opt-in lokal
214
+ server: null, // telemetry_enabled dari hello.ok (server)
215
+ noticed: false,
216
+ };
217
+ }
218
+
219
+ // Pemberitahuan konsent: sekali saja per sesi daemon (spek: never send + notice).
220
+ function noticeConsentOnce(consent) {
221
+ if (consent.noticed) return;
222
+ consent.noticed = true;
223
+ const l = consent.local === true ? 'lokal ✅' : 'lokal ❌ (luang-cli telemetry on)';
224
+ const s =
225
+ consent.server === true
226
+ ? 'server ✅'
227
+ : consent.server === false
228
+ ? 'server ❌ (nyalakan Telemetry di dashboard Agents)'
229
+ : 'server ? (info belum diterima)';
230
+ console.log(`ℹ️ Telemetry TIDAK dikirim (${l} · ${s}) — consent ganda harus ON bersamaan.`);
231
+ }
232
+
233
+ function tokensMaybe(cfg) {
234
+ const out = {};
235
+ const pick = (envName, cfgVal) => {
236
+ const e = process.env[envName];
237
+ if (e !== undefined && String(e).trim() !== '') {
238
+ const n = Number(e);
239
+ if (Number.isFinite(n)) return n;
240
+ }
241
+ if (typeof cfgVal === 'number' && Number.isFinite(cfgVal)) return cfgVal;
242
+ return null;
243
+ };
244
+ const tin = pick('LUANG_TOKENS_IN', cfg.tokens_in);
245
+ const tout = pick('LUANG_TOKENS_OUT', cfg.tokens_out);
246
+ if (tin !== null) out.tokens_in = tin;
247
+ if (tout !== null) out.tokens_out = tout;
248
+ return out;
249
+ }
250
+
251
+ function makeChannel(kind, opts) {
252
+ const { rt, ws, consent } = opts;
253
+ const ch = {
254
+ kind,
255
+ rt,
256
+ ws,
257
+ consent,
258
+ paused: false, // dari agent.command pause/resume
259
+ latestTasks: [],
260
+ execChain: Promise.resolve(),
261
+ // Tugas yang sudah diberi tahu "eksekusi nonaktif" (sekali per sesi).
262
+ inactiveNotified: new Set(),
263
+ // Update status tugas; label dipakai untuk pesan error HTTP mode saja.
264
+ sendStatus(code, body, label) {
265
+ if (kind === 'ws' && this.ws) {
266
+ try {
267
+ this.ws.send({ type: 'task.status', ts: Date.now(), data: { ref: String(code), ...body } });
268
+ } catch (e) {
269
+ console.error(`⚠️ Gagal kirim task.status: ${e.message}`);
270
+ }
271
+ return;
272
+ }
273
+ apiRequest(this.rt, 'POST', `/api/v1/agent/tasks/${encodeURIComponent(String(code))}/status`, { body })
274
+ .catch((e) => console.error(`⚠️ Gagal ${label || 'update status tugas'}: ${e.message}`));
275
+ },
276
+ ack(commandId, ok, message) {
277
+ if (kind !== 'ws' || !this.ws) return;
278
+ const data = { command_id: commandId, ok: !!ok };
279
+ if (message) data.message = String(message);
280
+ try {
281
+ this.ws.send({ type: 'command.ack', ts: Date.now(), data });
282
+ } catch (e) {
283
+ console.error(`⚠️ Gagal kirim command.ack: ${e.message}`);
284
+ }
285
+ },
286
+ // Telemetry.run — HANYA realtime; tanpa consent → didrop di CLI + notice sekali.
287
+ telemetry(payload) {
288
+ if (kind !== 'ws') return;
289
+ if (!(consent.local === true && consent.server === true)) {
290
+ noticeConsentOnce(consent);
291
+ return;
292
+ }
293
+ try {
294
+ this.ws.send({ type: 'telemetry.run', ts: Date.now(), data: payload });
295
+ } catch (e) {
296
+ console.error(`⚠️ Gagal kirim telemetry.run: ${e.message}`);
297
+ }
298
+ },
299
+ };
300
+ return ch;
301
+ }
302
+
303
+ async function tryExecTask(ch, tasks, state, execCfg, current) {
304
+ if (ch.paused) return false; // di-pause server → exec tidak dijalankan
84
305
  const cand = tasks.find((t) => c.statusInputNorm(t.status) === 'todo' && !state.executed[c.taskIdOf(t)]);
85
306
  if (!cand) return false;
86
307
  const code = c.taskIdOf(cand);
308
+ if (!execCfg.doExec) {
309
+ // Eksekusi NONAKTIF (tanpa --exec / mode auto) → jangan salahkan
310
+ // exec_template; beri tahu cara menyalakannya. Tugas TIDAK ditandai
311
+ // executed, supaya nanti masih bisa jalan saat mode exec dinyalakan.
312
+ if (!ch.inactiveNotified.has(code)) {
313
+ ch.inactiveNotified.add(code);
314
+ console.log(
315
+ `⏭ ${code} menunggu (To-Do) — eksekusi nonaktif (jalankan \`luang-cli agent --exec\` atau \`agent start\` untuk mode otomatis).`,
316
+ );
317
+ }
318
+ return false;
319
+ }
87
320
  const taskText = `${cand.title || ''}${cand.description ? ' — ' + cand.description : ''}`;
88
- const cmdStr = buildExecCommand(execCfg.template, taskText);
321
+ if (execCfg.template && execCfg.template.includes('{model}')) {
322
+ const model = String(execCfg.model || process.env.LUANG_MODEL || '').trim();
323
+ if (!model) {
324
+ // template memuat {model} tapi tidak ada model yang bisa dipakai → dilewati dengan notifikasi
325
+ state.executed[code] = true;
326
+ saveState(state);
327
+ console.log(
328
+ `⏭ ${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.`,
329
+ );
330
+ return true;
331
+ }
332
+ }
333
+ const cmdStr = buildExecCommand(execCfg.template, taskText, execCfg.model);
89
334
  if (!cmdStr) {
90
- // exec_template kosong / tanpa {task} → hanya notifikasi satu baris (spec)
335
+ // HANYA saat exec aktif tapi template kosong/tanpa {task} → notifikasi (spec)
91
336
  state.executed[code] = true; // tandai agar tidak nag lagi
92
337
  saveState(state);
93
338
  console.log(
@@ -101,85 +346,379 @@ async function tryExecTask(rt, tasks, state, execCfg, current) {
101
346
  console.log(`🎯 Eksekusi tugas: ${code} — ${trunc(cand.title, 60)}`);
102
347
  console.log(` Provider : ${execCfg.type}`);
103
348
  console.log(` Perintah : ${trunc(cmdStr, 90)}`);
104
- const ans = await c.ask(' Jalankan sekarang? (y/N) ');
349
+ // Workspace folder (dashboard/CLI): resolve + validasi; tidak valid → cwd proses + warning.
350
+ let execCwd = null;
351
+ const wsRaw = (ch.rt.cfg && ch.rt.cfg.workspace) || '';
352
+ if (String(wsRaw).trim()) {
353
+ const resolved = resolveWorkspace(wsRaw);
354
+ try {
355
+ const st = resolved && fs.statSync(resolved);
356
+ if (st && st.isDirectory()) {
357
+ execCwd = resolved;
358
+ } else {
359
+ console.log(` ⚠️ Workspace "${wsRaw}" tidak ada/bukan folder — pakai folder saat ini.`);
360
+ }
361
+ } catch (e) {
362
+ console.log(` ⚠️ Workspace "${wsRaw}" tak bisa diakses — pakai folder saat ini.`);
363
+ }
364
+ }
365
+ if (execCwd) console.log(` Folder : ${execCwd}`);
366
+ // Mode auto (agent start / --auto): TANPA konfirmasi — tidak ada terminal.
367
+ const ans = execCfg.auto ? 'y' : await c.ask(' Jalankan sekarang? (y/N) ');
368
+ if (execCfg.auto) console.log(' Konfirmasi: otomatis (mode auto, tanpa y/N).');
105
369
  state.executed[code] = true;
106
370
  if (ans !== 'y') {
107
371
  console.log(' Dilewati (tidak dijalankan).');
108
372
  saveState(state);
109
373
  return true;
110
374
  }
375
+ const model = String(execCfg.model || process.env.LUANG_MODEL || '') || undefined;
376
+ const toks = tokensMaybe(ch.rt.cfg);
377
+ const baseTel = { task_ref: code, task_code: code, provider: execCfg.type, model };
378
+ const t0 = Date.now();
111
379
  // 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
- }
380
+ ch.sendStatus(code, { status: 'in_progress', agent_status: 'menjalankan' }, 'set in_progress');
381
+ ch.telemetry({ ...baseTel, status: 'started', ...toks });
119
382
  const exitCode = await runExecChild(cmdStr, (child) => {
120
383
  current.child = child;
121
- });
384
+ }, execCwd);
122
385
  const ok = exitCode === 0;
123
386
  // Spesifikasi: exit 0 → {status:"done", agent_status:"selesai", progress_note:"exec exit 0"};
124
387
  // selain itu → {status:"review", agent_status:"gagal", progress_note:"exit code N"}.
388
+ ch.sendStatus(
389
+ code,
390
+ ok
391
+ ? { status: 'done', agent_status: 'selesai', progress_note: 'exec exit 0' }
392
+ : { status: 'review', agent_status: 'gagal', progress_note: `exit code ${exitCode}` },
393
+ 'update status tugas',
394
+ );
395
+ ch.telemetry({
396
+ ...baseTel,
397
+ status: ok ? 'completed' : 'failed',
398
+ exit_code: exitCode,
399
+ duration_ms: Date.now() - t0,
400
+ ...toks,
401
+ });
402
+ console.log(ok ? `✅ ${code} selesai (exit 0)` : `⚠️ ${code} gagal (exit ${exitCode}) → review`);
403
+ return true;
404
+ }
405
+
406
+ // Rantai eksekusi: event datang serentak → anak antre, tidak dobel.
407
+ function runExecMaybe(ch, state, execCfg, current) {
408
+ ch.execChain = ch.execChain
409
+ .then(() => tryExecTask(ch, ch.latestTasks, state, execCfg, current))
410
+ .then((acted) => {
411
+ if (acted) saveState(state);
412
+ })
413
+ .catch(() => {});
414
+ return ch.execChain;
415
+ }
416
+
417
+ // ── Encode waktu/config untuk WS ──────────────────────────────────────────────
418
+
419
+ function wsRealtimeUrl(apiUrl) {
125
420
  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
- });
421
+ const u = new URL(String(apiUrl));
422
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
423
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
424
+ let p = u.pathname.replace(/\/+$/, '');
425
+ p = p.endsWith('/api/v1') ? p + '/agent/ws' : p + '/api/v1/agent/ws';
426
+ u.pathname = p;
427
+ u.search = '';
428
+ return u.toString();
131
429
  } catch (e) {
132
- console.error(`⚠️ Gagal update status tugas: ${e.message}`);
430
+ return null;
133
431
  }
134
- console.log(ok ? `✅ ${code} selesai (exit 0)` : `⚠️ ${code} gagal (exit ${exitCode}) → review`);
135
- return true;
136
432
  }
137
433
 
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;
434
+ function canRealtime(rt) {
435
+ if (!wsLib) return false;
436
+ if (['1', 'true', 'yes'].includes(String(process.env.LUANG_NO_WS || '').toLowerCase())) return false;
437
+ return !!rt.key && !!wsRealtimeUrl(rt.apiUrl);
438
+ }
145
439
 
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;
440
+ // providers untuk hello: kontrak WS pakai {type, exec_template?, model?, models?}
441
+ function providersForHello(cfg) {
442
+ return collectProviders(cfg).map((p) => {
443
+ const o = { type: p.provider_type };
444
+ if (p.exec_template) o.exec_template = String(p.exec_template);
445
+ if (p.model) o.model = String(p.model);
446
+ // Kontrak baru (opsional): daftar model — maks 50 item × 120 karakter.
447
+ o.models = modelsForProvider(p.provider_type, { path: p.path }).models;
448
+ return o;
449
+ });
450
+ }
451
+
452
+ function hbIntervalMs() {
453
+ let s = Number(process.env.LUANG_HEARTBEAT_SEC);
454
+ if (!s || !Number.isFinite(s) || s < 3) s = 25; // WS default 25s (kontrak: ≤30s)
455
+ return Math.min(Math.floor(s), 60) * 1000;
456
+ }
457
+
458
+ // Sinkronkan execCfg ke provider default tertentu (dipakai hello.ok & agent.command
459
+ // set_default_provider) — template & model diambil dari config provider tsb.
460
+ function applyDefaultProvider(execCfg, cfg, type) {
461
+ execCfg.type = type;
462
+ const p = collectProviders(cfg).find((x) => x.provider_type === type);
463
+ execCfg.template = p ? p.exec_template || null : null;
464
+ execCfg.model = (p && p.model) || null;
465
+ }
466
+
467
+ // Baris "Eksekusi" di banner — pesan harus jujur sesuai mode:
468
+ // doExec=false → "nonaktif" (JANGAN menyalahkan exec_template);
469
+ // doExec=true tapi template kosong → baru menyebut exec_template.
470
+ function execBanner(doExec, execCfg, auto) {
471
+ if (!doExec) return 'nonaktif';
472
+ if (!execCfg.template || !execCfg.template.includes('{task}')) {
473
+ return 'diminta tapi nonaktif — default provider belum punya exec_template berisi {task}';
474
+ }
475
+ const via = auto ? 'otomatis tanpa konfirmasi' : 'konfirmasi y/N';
476
+ return `aktif (${via} · ${execCfg.type || '-'}${execCfg.model ? ` · model ${execCfg.model}` : ''})`;
477
+ }
478
+
479
+ // agent.command dari server: set_model | set_default_provider | pause | resume → command.ack
480
+ function handleAgentCommand(d, ctx) {
481
+ const { ch, rt, execCfg, state, current } = ctx;
482
+ const id = d.command_id || 'cmd';
483
+ const cmd = String(d.command || '').toLowerCase();
484
+ if (cmd === 'set_model') {
485
+ const type = d.provider || rt.cfg.default_provider;
486
+ const model = typeof d.model === 'string' ? d.model.trim() : '';
487
+ if (!type) {
488
+ ch.ack(id, false, 'Provider tidak diketahui & default provider belum di-set — jalankan: luang-cli provider default <type>');
489
+ return;
490
+ }
491
+ if (!model || !/^[\w.:+/-]+$/.test(model)) {
492
+ ch.ack(id, false, `Model tidak valid: ${JSON.stringify(d.model)}`);
493
+ return;
151
494
  }
495
+ const cur = (rt.cfg.providers && rt.cfg.providers[type]) || {};
496
+ rt.cfg.providers[type] = {
497
+ ...cur,
498
+ provider_type: type,
499
+ display_name: cur.display_name || DISPLAY_NAMES[type] || type,
500
+ model,
501
+ };
502
+ saveConfig(rt.cfg);
503
+ if (!execCfg.type || execCfg.type === type) execCfg.model = model;
504
+ console.log(`🎛 Perintah server: set_model → ${type} = ${model} (tersimpan)`);
505
+ ch.ack(id, true, `Model "${model}" tersimpan untuk provider ${type}`);
506
+ runExecMaybe(ch, state, execCfg, current); // tugas pending mungkin baru bisa jalan
507
+ } else if (cmd === 'set_default_provider') {
508
+ const type = typeof d.provider === 'string' ? d.provider.trim() : '';
509
+ if (!type) {
510
+ // Reset dari dashboard → kembali ke pilihan lokal CLI.
511
+ rt.cfg.default_provider = null;
512
+ saveConfig(rt.cfg);
513
+ console.log('🎛 Perintah server: default provider direset — memakai pilihan lokal CLI.');
514
+ ch.ack(id, true, 'default provider direset');
515
+ return;
516
+ }
517
+ const prev = rt.cfg.default_provider;
518
+ rt.cfg.default_provider = type;
519
+ saveConfig(rt.cfg);
520
+ if (!execCfg.type || execCfg.type === prev) applyDefaultProvider(execCfg, rt.cfg, type);
521
+ const known = collectProviders(rt.cfg).some((x) => x.provider_type === type);
522
+ console.log(`⭐ Perintah server: default provider → ${type} (tersimpan)`);
523
+ ch.ack(id, true, `default provider = ${type}${known ? '' : ' — provider belum ada di config lokal'}`);
524
+ runExecMaybe(ch, state, execCfg, current);
525
+ } else if (cmd === 'pause') {
526
+ ch.paused = true;
527
+ console.log('⏸ Perintah server: PAUSE — eksekusi ditunda (heartbeat tetap).');
528
+ ch.ack(id, true, 'paused');
529
+ } else if (cmd === 'resume') {
530
+ ch.paused = false;
531
+ console.log('▶️ Perintah server: RESUME — eksekusi tugas dilanjutkan.');
532
+ ch.ack(id, true, 'resumed');
533
+ runExecMaybe(ch, state, execCfg, current);
534
+ } else {
535
+ ch.ack(id, false, `Perintah tidak dikenal: ${cmd}`);
152
536
  }
537
+ }
153
538
 
154
- const state = loadState();
155
- const current = { child: null };
156
- let running = true;
539
+ // ── Mode realtime (WS) ────────────────────────────────────────────────────────
157
540
 
158
- console.log('🤖 Agent Luang aktif');
541
+ function runAgentRealtime(rt, opts) {
542
+ const { execCfg, state, current, doExec } = opts;
543
+ const wsUrl = wsRealtimeUrl(rt.apiUrl);
544
+ if (!wsUrl) return Promise.resolve('fatal');
545
+ const ws = new wsLib.WsClient(wsUrl, { headers: { 'X-CLI-Key': rt.key } });
546
+ current.ws = ws;
547
+ const consent = makeConsent(rt.cfg);
548
+ const ch = makeChannel('ws', { rt, ws, consent });
549
+
550
+ console.log('🤖 Agent Luang aktif (realtime WebSocket)');
551
+ console.log(` WS : ${wsUrl}`);
159
552
  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'}`);
553
+ console.log(` Heartbeat : frame tiap ≤30s (interval ${Math.round(hbIntervalMs() / 1000)}s)`);
554
+ console.log(` Eksekusi : ${execBanner(doExec, execCfg, execCfg.auto)}`);
555
+ console.log(` Telemetry : ${consent.local === true ? 'config ON (butuh flag server ON juga)' : 'OFF (luang-cli telemetry on)'}`);
162
556
  console.log(` State : ${STATE_FILE}`);
163
- console.log(' Hentikan : Ctrl+C');
557
+ console.log(execCfg.auto ? ' Hentikan : luang-cli agent stop (SIGTERM)' : ' Hentikan : Ctrl+C');
164
558
  console.log('');
559
+ console.log('⏳ Menunggu handshake realtime…');
165
560
 
166
- process.on('SIGINT', () => {
167
- running = false;
168
- if (current.child) {
561
+ return new Promise((resolve) => {
562
+ let settled = false;
563
+ let established = false;
564
+ let firstSync = true;
565
+ let hbTimer = null;
566
+
567
+ const settle = (v) => {
568
+ if (settled) return;
569
+ settled = true;
570
+ clearTimeout(guard);
571
+ if (hbTimer) {
572
+ clearInterval(hbTimer);
573
+ hbTimer = null;
574
+ }
575
+ if (v !== 'ok') ws.close(); // beres: jangan reconnect setelah fallback
576
+ resolve(v);
577
+ };
578
+
579
+ const guard = setTimeout(() => {
580
+ if (!established) settle('fallback');
581
+ }, REALTIME_GUARD_MS);
582
+
583
+ ws.on('open', () => {
169
584
  try {
170
- current.child.kill('SIGINT');
585
+ ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
171
586
  } catch (e) {
172
- // abaikan
587
+ console.error(`⚠️ Gagal kirim hello: ${e.message}`);
173
588
  }
174
- }
175
- console.log('\n👋 Agent dihentikan.');
176
- process.exit(0);
589
+ if (established) console.log(`🔌 WS terhubung ulang (${stampNow()}) — hello dikirim ulang`);
590
+ });
591
+ ws.on('reconnecting', ({ delay, reason }) => {
592
+ console.log(`🔁 WS terputus (${trunc(String(reason), 40)}) — mencoba ulang dalam ${(delay / 1000).toFixed(1)}s…`);
593
+ });
594
+ ws.on('error', () => {
595
+ // Gagal sambung sebelum/fallback cukup jelas dari fallback notice; setelah isi, event reconnects sudah dicetak.
596
+ });
597
+ ws.on('fatal', (e) => {
598
+ console.error(`❌ WS ditolak server: ${e.message}`);
599
+ settle('fatal');
600
+ });
601
+
602
+ hbTimer = setInterval(() => {
603
+ try {
604
+ ws.send({ type: 'heartbeat', ts: Date.now(), data: {} });
605
+ } catch (e) {
606
+ // ws lib mengantre saat reconnect; biarkan
607
+ }
608
+ }, hbIntervalMs());
609
+
610
+ ws.on('message', (raw) => {
611
+ let msg;
612
+ try {
613
+ msg = JSON.parse(raw);
614
+ } catch (e) {
615
+ return; // pesan invalid → abaikan (server yang benar tidak mengirim ini)
616
+ }
617
+ if (!msg || typeof msg.type !== 'string') return;
618
+ const d = msg.data && typeof msg.data === 'object' ? msg.data : {};
619
+ const ctx = { ch, rt, execCfg, state, current };
620
+
621
+ if (msg.type === 'hello.ok') {
622
+ consent.server = d.telemetry_enabled === true;
623
+ // Sinkron default provider pilihan dashboard → config lokal (saat beda).
624
+ const sdp = typeof d.default_provider === 'string' ? d.default_provider.trim() : '';
625
+ if (sdp && rt.cfg.default_provider !== sdp) {
626
+ const prev = rt.cfg.default_provider;
627
+ rt.cfg.default_provider = sdp;
628
+ saveConfig(rt.cfg);
629
+ if (!execCfg.type || execCfg.type === prev) applyDefaultProvider(execCfg, rt.cfg, sdp);
630
+ console.log(`⭐ Default provider sinkron dari dashboard: ${sdp}`);
631
+ }
632
+ // Sinkron workspace folder pilihan dashboard → config lokal (saat beda).
633
+ const sws = typeof d.workspace === 'string' ? d.workspace.trim() : '';
634
+ if (sws && rt.cfg.workspace !== sws) {
635
+ rt.cfg.workspace = sws;
636
+ saveConfig(rt.cfg);
637
+ console.log(`📁 Workspace sinkron dari dashboard: ${sws}`);
638
+ }
639
+ if (!established) {
640
+ established = true;
641
+ clearTimeout(guard);
642
+ console.log(`🔌 Realtime aktif · key #${d.key_id ?? '-'} · user #${d.user_id ?? '-'}${d.server_time ? ` · server_time ${d.server_time}` : ''}`);
643
+ console.log(' Event live: task.snapshot / task.assigned / task.updated · agent.command');
644
+ if (consent.local === true && consent.server === true) {
645
+ console.log('📈 Telemetry consent ON (lokal ✅ · server ✅) — event run dikirim realtime.');
646
+ } else {
647
+ noticeConsentOnce(consent);
648
+ }
649
+ }
650
+ return;
651
+ }
652
+ if (!established) return;
653
+
654
+ if (msg.type === 'task.snapshot') {
655
+ const tasks = c.normalizeTaskList(d);
656
+ let dirty = false;
657
+ const seen = new Set();
658
+ for (const t of tasks) {
659
+ const id = c.taskIdOf(t);
660
+ if (!id) continue;
661
+ seen.add(id);
662
+ if (ingestTaskIntoState(t, state)) {
663
+ dirty = true;
664
+ printTaskLine(t, firstSync ? 'sinkron awal' : 'berubah');
665
+ }
666
+ }
667
+ for (const id of Object.keys(state.tasks)) {
668
+ if (!seen.has(id)) {
669
+ delete state.tasks[id];
670
+ dirty = true;
671
+ }
672
+ }
673
+ firstSync = false;
674
+ ch.latestTasks = tasks;
675
+ if (dirty) saveState(state);
676
+ runExecMaybe(ch, state, execCfg, current);
677
+ } else if (msg.type === 'task.updated' || msg.type === 'task.assigned') {
678
+ const t = d.task && typeof d.task === 'object' ? d.task : d.id ? d : null;
679
+ if (!t) return;
680
+ if (ingestTaskIntoState(t, state)) {
681
+ printTaskLine(t, msg.type === 'task.assigned' ? 'di-assign' : 'berubah');
682
+ saveState(state);
683
+ }
684
+ const id = c.taskIdOf(t);
685
+ const i = ch.latestTasks.findIndex((x) => c.taskIdOf(x) === id);
686
+ if (i >= 0) ch.latestTasks[i] = t;
687
+ else ch.latestTasks.push(t);
688
+ runExecMaybe(ch, state, execCfg, current);
689
+ } else if (msg.type === 'agent.command') {
690
+ handleAgentCommand(d, ctx);
691
+ } else if (msg.type === 'error') {
692
+ console.error(`⚠️ WS server error: ${d.code || '-'} ${trunc(String(d.message || ''), 120)}`);
693
+ }
694
+ // heartbeat / agent.online / lainnya diabaikan (bukan untuk CLI)
695
+ });
696
+
697
+ ws.connect();
177
698
  });
699
+ }
700
+
701
+ // ── Mode polling (fallback, perilaku lama) ───────────────────────────────────
702
+
703
+ async function runAgentPolling(rt, opts) {
704
+ const { intervalSec, doExec, execCfg, state, current } = opts;
705
+ const ch = makeChannel('http', { rt });
706
+ let running = true;
707
+
708
+ console.log('🤖 Agent Luang aktif');
709
+ console.log(` API : ${rt.apiUrl}`);
710
+ console.log(` Poll : setiap ${intervalSec} detik · heartbeat setiap 30 detik`);
711
+ console.log(' Realtime : nonaktif — polling HTTP (WS gagal/dimatikan)');
712
+ console.log(' Telemetry : tidak dikirim (mode polling tanpa kanal realtime)');
713
+ console.log(` Eksekusi : ${execBanner(doExec, execCfg, execCfg.auto)}`);
714
+ console.log(` State : ${STATE_FILE}`);
715
+ console.log(execCfg.auto ? ' Hentikan : luang-cli agent stop (SIGTERM)' : ' Hentikan : Ctrl+C');
716
+ console.log('');
178
717
 
179
718
  const heartbeat = async (label) => {
180
719
  try {
181
720
  await apiRequest(rt, 'POST', '/api/v1/agent/heartbeat', {
182
- body: { providers: core.heartbeatProviders(collectProviders(rt.cfg)) },
721
+ body: { providers: heartbeatProviders(collectProviders(rt.cfg)) },
183
722
  });
184
723
  console.log(`📡 ${label} terkirim`);
185
724
  } catch (e) {
@@ -187,24 +726,26 @@ async function cmdAgent(rt, args) {
187
726
  }
188
727
  };
189
728
 
190
- const hbInterval = setInterval(() => {
729
+ let hbTimer = setInterval(() => {
191
730
  if (running) heartbeat('Heartbeat');
192
- }, 30000);
731
+ }, hbIntervalSec() * 1000);
193
732
 
194
733
  let first = true;
195
- while (running) {
734
+ let runningLoop = true;
735
+ while (runningLoop) {
196
736
  let tasks;
197
737
  try {
198
738
  const res = await apiRequest(rt, 'GET', '/api/v1/agent/tasks');
199
739
  tasks = c.normalizeTaskList(res);
200
740
  } catch (e) {
201
741
  if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
202
- clearInterval(hbInterval);
742
+ clearInterval(hbTimer);
743
+ runningLoop = false;
203
744
  console.error(`❌ Key ditolak server (HTTP ${e.status}): ${e.message}`);
204
745
  console.error(' Cek: luang-cli whoami · login ulang: luang-cli login');
205
746
  return 1;
206
747
  }
207
- console.error(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
748
+ console.log(`⚠️ Poll gagal: ${e.message} — coba lagi dalam ${intervalSec} detik…`);
208
749
  await sleep(intervalSec * 1000);
209
750
  continue;
210
751
  }
@@ -215,21 +756,11 @@ async function cmdAgent(rt, args) {
215
756
  const id = c.taskIdOf(t);
216
757
  if (!id) continue;
217
758
  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 };
759
+ if (ingestTaskIntoState(t, state)) {
228
760
  dirty = true;
229
761
  printTaskLine(t, first ? 'baru dipantau' : 'berubah');
230
762
  }
231
763
  }
232
- // bersihkan cache untuk tugas yang tak lagi dikirim server
233
764
  for (const id of Object.keys(state.tasks)) {
234
765
  if (!seen.has(id)) {
235
766
  delete state.tasks[id];
@@ -238,15 +769,256 @@ async function cmdAgent(rt, args) {
238
769
  }
239
770
  first = false;
240
771
  if (dirty) saveState(state);
772
+ ch.latestTasks = tasks;
241
773
 
242
- if (doExec) {
243
- const acted = await tryExecTask(rt, tasks, state, execCfg, current);
244
- if (acted) saveState(state);
245
- }
774
+ // Sama seperti mode realtime: cek eksekusi tiap siklus (notice "eksekusi
775
+ // nonaktif" dicetak bila doExec=false; eksekusi jalan hanya bila doExec).
776
+ runExecMaybe(ch, state, execCfg, current);
246
777
 
247
778
  await sleep(intervalSec * 1000);
248
779
  }
249
- clearInterval(hbInterval);
780
+ clearInterval(hbTimer);
781
+ return 0;
782
+ }
783
+
784
+ function hbIntervalSec() {
785
+ let s = Number(process.env.LUANG_HEARTBEAT_SEC);
786
+ if (!s || !Number.isFinite(s) || s < 3) s = 30; // HTTP polling: 30s (perilaku lama)
787
+ return Math.min(Math.floor(s), 3600);
788
+ }
789
+
790
+ async function cmdAgent(rt, args) {
791
+ // Subcommand background: agent start | stop | status | logs.
792
+ const sub = args.pos[1];
793
+ if (sub === 'start') return agentStart(rt, args);
794
+ if (sub === 'stop') return agentStop(rt, args);
795
+ if (sub === 'status') return agentStatus(rt, args);
796
+ if (sub === 'logs') return agentLogs(rt, args);
797
+ if (sub !== undefined && !String(sub).startsWith('-')) {
798
+ console.error(`❌ Sub-command agent tidak dikenal: ${sub}`);
799
+ console.error(' Gunakan: (foreground) --exec | --auto · atau start | stop | status | logs');
800
+ console.error(' Bantuan: luang-cli help agent');
801
+ return 1;
802
+ }
803
+
804
+ if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
805
+ const flags = args.flags;
806
+ let intervalSec = Number(flags.interval);
807
+ if (!intervalSec || intervalSec < 1) intervalSec = 5;
808
+ if (intervalSec > 3600) intervalSec = 3600;
809
+ // --auto (dipakai anak proses `agent start`): doExec=true TANPA y/N karena
810
+ // tidak ada terminal. Foreground `agent` / `agent --exec` tetap perilaku lama.
811
+ const auto = flags.auto === true;
812
+ const doExec = flags.exec === true || auto;
813
+
814
+ const execCfg = { type: rt.cfg.default_provider, template: null, model: null, doExec, auto };
815
+ if (doExec) {
816
+ // Selalu isi dari collectProviders (baik flag --exec maupun mode auto).
817
+ const p = collectProviders(rt.cfg).find((x) => x.provider_type === rt.cfg.default_provider);
818
+ if (p) {
819
+ execCfg.template = p.exec_template || null;
820
+ execCfg.model = p.model || null;
821
+ }
822
+ }
823
+
824
+ const state = loadState();
825
+ const current = { child: null, ws: null };
826
+
827
+ // Ctrl+C (SIGINT) dan `agent stop` (SIGTERM) memakai jalur yang sama.
828
+ const shutdown = () => {
829
+ if (current.child) {
830
+ try {
831
+ current.child.kill('SIGINT');
832
+ } catch (e) {
833
+ // abaikan
834
+ }
835
+ }
836
+ if (current.ws) {
837
+ try {
838
+ current.ws.close();
839
+ } catch (e) {
840
+ // abaikan
841
+ }
842
+ }
843
+ console.log('\n👋 Agent dihentikan.');
844
+ process.exit(0);
845
+ };
846
+ process.on('SIGINT', shutdown);
847
+ process.on('SIGTERM', shutdown);
848
+
849
+ // 1) Realtime WebSocket — enhancement, bukan syarat (kontrak §5).
850
+ if (canRealtime(rt)) {
851
+ const mode = await runAgentRealtime(rt, { doExec, execCfg, state, current });
852
+ if (mode === 'ok') return 0; // sampai Ctrl+C (process.exit pada handler)
853
+ console.log('');
854
+ if (mode === 'fatal') {
855
+ console.log('❌ Server menolak/menyediakan WS — beralih ke fallback polling HTTP.');
856
+ } else {
857
+ console.log('⚠️ WS gagal terhubung dalam batas waktu — beralih ke fallback polling HTTP.');
858
+ }
859
+ console.log('');
860
+ } else if (!wsLib) {
861
+ console.log('⚠️ lib/ws tidak tersedia di Node ini — beralih ke fallback polling HTTP.');
862
+ console.log('');
863
+ }
864
+
865
+ // 2) Fallback polling HTTP klasik.
866
+ return runAgentPolling(rt, { intervalSec, doExec, execCfg, state, current });
867
+ }
868
+
869
+ // ── agent start | stop | status | logs (daemon di background) ────────────────
870
+
871
+ // Mulai agent sebagai proses TERPISAH (detached): output → ~/.luang-cli/agent.log,
872
+ // pid → ~/.luang-cli/agent.pid (JSON {pid, started_at, auto, mode}).
873
+ async function agentStart(rt, args) {
874
+ if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
875
+ const flags = args.flags;
876
+ // Default `agent start` = mode auto; `--no-auto` mematikan (monitor saja).
877
+ const auto = flags['no-auto'] !== true;
878
+ rt.cfg.auto_exec = auto;
879
+ saveConfig(rt.cfg);
880
+
881
+ const existing = readPidInfo();
882
+ if (existing && isPidAlive(existing.pid)) {
883
+ console.log(`ℹ️ Agent sudah berjalan (pid ${existing.pid}${existing.auto ? ' · mode auto' : ' · mode manual'}) · log: ${tildePath(LOG_FILE)}`);
884
+ console.log(' Hentikan dulu: luang-cli agent stop');
885
+ return 0;
886
+ }
887
+ if (existing) removePidInfo(); // pid basi (prosesnya sudah mati)
888
+
889
+ const childArgs = ['agent'];
890
+ if (auto) childArgs.push('--auto');
891
+ if (flags.api !== undefined && flags.api !== true) childArgs.push('--api', String(flags.api));
892
+ if (flags.interval !== undefined && flags.interval !== true) childArgs.push('--interval', String(flags.interval));
893
+
894
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
895
+ const fd = fs.openSync(LOG_FILE, 'a');
896
+ let child;
897
+ try {
898
+ child = spawn(process.execPath, [BIN, ...childArgs], {
899
+ detached: true,
900
+ stdio: ['ignore', fd, fd],
901
+ env: process.env,
902
+ cwd: process.cwd(),
903
+ windowsHide: true,
904
+ });
905
+ } catch (e) {
906
+ fs.closeSync(fd);
907
+ return c.errExit(`Gagal spawn proses agent: ${e.message}`);
908
+ }
909
+ fs.closeSync(fd);
910
+ if (!child || !child.pid) return c.errExit('Gagal spawn proses agent (pid tidak ada).');
911
+ child.unref();
912
+
913
+ const info = {
914
+ pid: child.pid,
915
+ started_at: new Date().toISOString(),
916
+ auto,
917
+ mode: auto ? 'auto' : 'manual',
918
+ };
919
+ writePidInfo(info);
920
+ console.log(`🚀 Agent berjalan di background (pid ${child.pid}) · log: ${tildePath(LOG_FILE)}`);
921
+ console.log(` Mode : ${auto ? 'auto (eksekusi tugas To-Do tanpa konfirmasi y/N)' : 'manual (eksekusi nonaktif)'}`);
922
+
923
+ // Pastikan anak tidak langsung mati (mis. gagal start) — bukan sekadar asumsi.
924
+ await sleep(700);
925
+ if (!isPidAlive(child.pid)) {
926
+ const tail = tailLog(6) || [];
927
+ console.error('❌ Proses agent keluar segera setelah start — cek log:');
928
+ for (const l of tail) console.error(' | ' + l);
929
+ removePidInfo();
930
+ return 1;
931
+ }
932
+ return 0;
933
+ }
934
+
935
+ // Hentikan agent background: SIGTERM (graceful), handle bila sudah mati.
936
+ async function agentStop() {
937
+ const info = readPidInfo();
938
+ if (!info) {
939
+ console.log('ℹ️ Agent tidak berjalan (tidak ada ~/.luang-cli/agent.pid).');
940
+ return 0;
941
+ }
942
+ if (!isPidAlive(info.pid)) {
943
+ removePidInfo();
944
+ console.log(`ℹ️ Proses ${info.pid} sudah mati — agent.pid dibersihkan.`);
945
+ return 0;
946
+ }
947
+ try {
948
+ process.kill(info.pid, 'SIGTERM');
949
+ } catch (e) {
950
+ if (e && (e.code === 'ESRCH' || e.code === 'ENOENT')) {
951
+ removePidInfo();
952
+ console.log(`ℹ️ Proses ${info.pid} sudah mati — agent.pid dibersihkan.`);
953
+ return 0;
954
+ }
955
+ return c.errExit(`Gagal mengirim SIGTERM ke pid ${info.pid}: ${e.message}`);
956
+ }
957
+ let dead = false;
958
+ for (let i = 0; i < 50; i++) {
959
+ if (!isPidAlive(info.pid)) {
960
+ dead = true;
961
+ break;
962
+ }
963
+ await sleep(100);
964
+ }
965
+ if (!dead) {
966
+ console.error(`⚠️ Pid ${info.pid} belum mati setelah 5 detik (SIGTERM) — coba lagi, cek log: ${tildePath(LOG_FILE)}`);
967
+ return 1;
968
+ }
969
+ removePidInfo();
970
+ console.log(`✅ Agent (pid ${info.pid}) dihentikan (SIGTERM, graceful).`);
971
+ return 0;
972
+ }
973
+
974
+ // Status: pid, uptime, mode auto/manual, config auto_exec, 3 baris log terakhir.
975
+ async function agentStatus(rt) {
976
+ console.log('🔎 Agent status');
977
+ const autoCfg = rt.cfg.auto_exec === true ? 'true' : 'false';
978
+ const info = readPidInfo();
979
+ if (!info) {
980
+ console.log(' Proses : ❌ tidak berjalan (tidak ada agent.pid)');
981
+ console.log(` Auto exec : config auto_exec = ${autoCfg}`);
982
+ console.log(` Log : ${tildePath(LOG_FILE)}${fs.existsSync(LOG_FILE) ? '' : ' (belum ada)'}`);
983
+ console.log(' Mulai : luang-cli agent start');
984
+ return 0;
985
+ }
986
+ const alive = isPidAlive(info.pid);
987
+ console.log(` PID : ${info.pid} (${alive ? 'hidup ✅' : 'mati ❌ — pid file basi'})`);
988
+ let startedMs = info.started_at ? Date.parse(info.started_at) : NaN;
989
+ if (!Number.isFinite(startedMs)) {
990
+ try {
991
+ startedMs = fs.statSync(PID_FILE).mtimeMs; // fallback: mtime pid file
992
+ } catch (e) {
993
+ startedMs = NaN;
994
+ }
995
+ }
996
+ console.log(` Uptime : ${Number.isFinite(startedMs) ? fmtUptime(Date.now() - startedMs) : '-'} (mulai ${info.started_at || '-'})`);
997
+ console.log(` Mode : ${info.auto ? 'auto (otomatis tanpa konfirmasi)' : 'manual (eksekusi nonaktif)'}`);
998
+ console.log(` Auto exec : config auto_exec = ${autoCfg}`);
999
+ console.log(` Log : ${tildePath(LOG_FILE)}`);
1000
+ const lines = tailLog(3);
1001
+ if (lines && lines.length) {
1002
+ console.log(' ── log (3 terakhir) ──');
1003
+ for (const l of lines) console.log(' ' + l);
1004
+ } else {
1005
+ console.log(' (log kosong / belum ada)');
1006
+ }
1007
+ if (!alive) console.log(' Bersihkan : luang-cli agent stop');
1008
+ return 0;
1009
+ }
1010
+
1011
+ // Tail log agent (default 30 baris).
1012
+ async function agentLogs(rt, args) {
1013
+ let n = Number(args.flags.tail);
1014
+ if (!Number.isFinite(n) || n < 1) n = 30;
1015
+ n = Math.min(Math.floor(n), 5000);
1016
+ const lines = tailLog(n);
1017
+ if (!lines) {
1018
+ console.log(`ℹ️ Log belum ada: ${tildePath(LOG_FILE)}`);
1019
+ return 0;
1020
+ }
1021
+ for (const l of lines) console.log(l);
250
1022
  return 0;
251
1023
  }
252
1024
 
@@ -383,18 +1155,34 @@ async function cmdStatus(rt) {
383
1155
  console.log(' ➡️ Mulai: luang-cli login');
384
1156
  return 0;
385
1157
  }
1158
+ let d = null;
386
1159
  try {
387
1160
  const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
388
- const d = (me && me.data) || {};
1161
+ d = (me && me.data) || {};
389
1162
  console.log(` Sesi : ${d.name || '-'} (${d.key_id || 'key-?'}) · status ${d.status || '-'}`);
390
1163
  console.log(` Tenant: ${d.tenant_slug || d.tenant_id || '-'}`);
391
1164
  console.log(` Izin : update-tugas ${d.can_update_tasks ? '✅' : '❌'} · buat-tugas ${d.can_create_tasks ? '✅' : '❌'}`);
1165
+ console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, d.telemetry_enabled === true)} (ubah lokal: telemetry on|off)`);
392
1166
  console.log(` Provider server: ${c.providersLabel(d.providers)}`);
393
1167
  } catch (e) {
394
1168
  console.log(` ⚠️ /me gagal: ${e.message}`);
1169
+ console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, null)}`);
1170
+ }
1171
+ // Sinkron default provider dari dashboard (field server, kosong = tak diset).
1172
+ const sdp = typeof (d && d.default_provider) === 'string' ? d.default_provider.trim() : '';
1173
+ if (sdp && rt.cfg.default_provider !== sdp) {
1174
+ rt.cfg.default_provider = sdp;
1175
+ saveConfig(rt.cfg);
1176
+ }
1177
+ // Sinkron workspace folder dari dashboard (field server, kosong = tak diset).
1178
+ const sws = typeof (d && d.workspace) === 'string' ? d.workspace.trim() : '';
1179
+ if (sws && rt.cfg.workspace !== sws) {
1180
+ rt.cfg.workspace = sws;
1181
+ saveConfig(rt.cfg);
395
1182
  }
396
1183
  const list = collectProviders(rt.cfg);
397
- console.log(` Default provider: ${rt.cfg.default_provider || '(belum di-set)'}`);
1184
+ console.log(` Default provider: ${rt.cfg.default_provider || '(belum di-set)'}${sdp ? ' ⭐ disetel dashboard' : ''}`);
1185
+ console.log(` Workspace: ${rt.cfg.workspace || '(folder saat ini)'}${sws ? ' 📁 disetel dashboard' : ''}`);
398
1186
  console.log(` Provider lokal : ${list.length ? list.map((p) => (p.is_default ? '★' + p.provider_type : p.provider_type)).join(', ') : '(tidak ada)'}`);
399
1187
  console.log(` Config/state : ${CONFIG_DIR.replace(process.env.HOME || '~', '~')}`);
400
1188
  return 0;
@@ -422,4 +1210,4 @@ async function cmdMcpConfig(rt) {
422
1210
  return 0;
423
1211
  }
424
1212
 
425
- module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild };
1213
+ module.exports = { cmdAgent, cmdTask, cmdStatus, cmdMcpConfig, buildExecCommand, runExecChild, wsRealtimeUrl, canRealtime, providersForHello, hbIntervalMs, makeChannel, handleAgentCommand, ingestTaskIntoState };