luang-cli 0.3.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
@@ -5,6 +5,8 @@
5
5
  // Bagian lanjutan dari lib/commands.js.
6
6
 
7
7
  const { spawn } = require('child_process');
8
+ const fs = require('fs');
9
+ const path = require('path');
8
10
  const pkg = require('../package.json');
9
11
 
10
12
  // lib/ws bisa gagal dimuat di Node yang sangat tua → daemon tetap jalan via polling.
@@ -25,10 +27,12 @@ const {
25
27
  saveConfig,
26
28
  collectProviders,
27
29
  heartbeatProviders,
30
+ resolveWorkspace,
28
31
  DEFAULT_TEMPLATES,
29
32
  DISPLAY_NAMES,
30
33
  STATE_FILE,
31
34
  CONFIG_DIR,
35
+ modelsForProvider,
32
36
  fmtWhen,
33
37
  keyPrefix,
34
38
  trunc,
@@ -41,6 +45,11 @@ const STATUS_LABELS = { todo: 'To-Do', in_progress: 'In Progress', review: 'Revi
41
45
  const WS_PATH = '/api/v1/agent/ws'; // kontrak: docs/agent-realtime-ws.md §1
42
46
  const REALTIME_GUARD_MS = 12000; // beri waktu beberapa percobaan WS sebelum fallback
43
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
+
44
53
  // ── agent (daemon) ───────────────────────────────────────────────────────────
45
54
 
46
55
  function stampNow() {
@@ -49,6 +58,80 @@ function stampNow() {
49
58
  return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
50
59
  }
51
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
+
52
135
  // Deteksi perubahan lebih luas dari updated_at saja: status, agent_status, note & waktu.
53
136
  function taskIdFingerprint(t) {
54
137
  return JSON.stringify([t.status || '', t.agent_status || '', t.agent_progress_note || '', t.agent_updated_at || '', t.updated_at || '', t.title || '']);
@@ -95,13 +178,14 @@ function buildExecCommand(tpl, taskText, rawModel) {
95
178
  return t.replaceAll('{task}', q);
96
179
  }
97
180
 
98
- function runExecChild(cmd, setChild) {
181
+ function runExecChild(cmd, setChild, cwd) {
99
182
  const EXEC_TIMEOUT_MS = 15 * 60 * 1000; // exec dibatasi 15 menit agar daemon tidak menggantung
100
183
  return new Promise((resolve) => {
101
184
  let child = null;
102
185
  try {
103
186
  // stdio inherit → output provider (mis. "opencode run") tampil live di terminal.
104
- child = spawn(cmd, [], { shell: true, stdio: 'inherit', windowsHide: true });
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() });
105
189
  } catch (e) {
106
190
  resolve(1);
107
191
  return;
@@ -174,6 +258,8 @@ function makeChannel(kind, opts) {
174
258
  paused: false, // dari agent.command pause/resume
175
259
  latestTasks: [],
176
260
  execChain: Promise.resolve(),
261
+ // Tugas yang sudah diberi tahu "eksekusi nonaktif" (sekali per sesi).
262
+ inactiveNotified: new Set(),
177
263
  // Update status tugas; label dipakai untuk pesan error HTTP mode saja.
178
264
  sendStatus(code, body, label) {
179
265
  if (kind === 'ws' && this.ws) {
@@ -219,6 +305,18 @@ async function tryExecTask(ch, tasks, state, execCfg, current) {
219
305
  const cand = tasks.find((t) => c.statusInputNorm(t.status) === 'todo' && !state.executed[c.taskIdOf(t)]);
220
306
  if (!cand) return false;
221
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
+ }
222
320
  const taskText = `${cand.title || ''}${cand.description ? ' — ' + cand.description : ''}`;
223
321
  if (execCfg.template && execCfg.template.includes('{model}')) {
224
322
  const model = String(execCfg.model || process.env.LUANG_MODEL || '').trim();
@@ -234,7 +332,7 @@ async function tryExecTask(ch, tasks, state, execCfg, current) {
234
332
  }
235
333
  const cmdStr = buildExecCommand(execCfg.template, taskText, execCfg.model);
236
334
  if (!cmdStr) {
237
- // exec_template kosong / tanpa {task} → hanya notifikasi satu baris (spec)
335
+ // HANYA saat exec aktif tapi template kosong/tanpa {task} → notifikasi (spec)
238
336
  state.executed[code] = true; // tandai agar tidak nag lagi
239
337
  saveState(state);
240
338
  console.log(
@@ -248,7 +346,26 @@ async function tryExecTask(ch, tasks, state, execCfg, current) {
248
346
  console.log(`🎯 Eksekusi tugas: ${code} — ${trunc(cand.title, 60)}`);
249
347
  console.log(` Provider : ${execCfg.type}`);
250
348
  console.log(` Perintah : ${trunc(cmdStr, 90)}`);
251
- 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).');
252
369
  state.executed[code] = true;
253
370
  if (ans !== 'y') {
254
371
  console.log(' Dilewati (tidak dijalankan).');
@@ -264,7 +381,7 @@ async function tryExecTask(ch, tasks, state, execCfg, current) {
264
381
  ch.telemetry({ ...baseTel, status: 'started', ...toks });
265
382
  const exitCode = await runExecChild(cmdStr, (child) => {
266
383
  current.child = child;
267
- });
384
+ }, execCwd);
268
385
  const ok = exitCode === 0;
269
386
  // Spesifikasi: exit 0 → {status:"done", agent_status:"selesai", progress_note:"exec exit 0"};
270
387
  // selain itu → {status:"review", agent_status:"gagal", progress_note:"exit code N"}.
@@ -320,12 +437,14 @@ function canRealtime(rt) {
320
437
  return !!rt.key && !!wsRealtimeUrl(rt.apiUrl);
321
438
  }
322
439
 
323
- // providers untuk hello: kontrak WS pakai {type, exec_template?, model?}
440
+ // providers untuk hello: kontrak WS pakai {type, exec_template?, model?, models?}
324
441
  function providersForHello(cfg) {
325
442
  return collectProviders(cfg).map((p) => {
326
443
  const o = { type: p.provider_type };
327
444
  if (p.exec_template) o.exec_template = String(p.exec_template);
328
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;
329
448
  return o;
330
449
  });
331
450
  }
@@ -336,7 +455,28 @@ function hbIntervalMs() {
336
455
  return Math.min(Math.floor(s), 60) * 1000;
337
456
  }
338
457
 
339
- // agent.command dari server: set_model | pause | resume → command.ack
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
340
480
  function handleAgentCommand(d, ctx) {
341
481
  const { ch, rt, execCfg, state, current } = ctx;
342
482
  const id = d.command_id || 'cmd';
@@ -364,6 +504,24 @@ function handleAgentCommand(d, ctx) {
364
504
  console.log(`🎛 Perintah server: set_model → ${type} = ${model} (tersimpan)`);
365
505
  ch.ack(id, true, `Model "${model}" tersimpan untuk provider ${type}`);
366
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);
367
525
  } else if (cmd === 'pause') {
368
526
  ch.paused = true;
369
527
  console.log('⏸ Perintah server: PAUSE — eksekusi ditunda (heartbeat tetap).');
@@ -393,10 +551,10 @@ function runAgentRealtime(rt, opts) {
393
551
  console.log(` WS : ${wsUrl}`);
394
552
  console.log(` API : ${rt.apiUrl}`);
395
553
  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'}`);
554
+ console.log(` Eksekusi : ${execBanner(doExec, execCfg, execCfg.auto)}`);
397
555
  console.log(` Telemetry : ${consent.local === true ? 'config ON (butuh flag server ON juga)' : 'OFF (luang-cli telemetry on)'}`);
398
556
  console.log(` State : ${STATE_FILE}`);
399
- console.log(' Hentikan : Ctrl+C');
557
+ console.log(execCfg.auto ? ' Hentikan : luang-cli agent stop (SIGTERM)' : ' Hentikan : Ctrl+C');
400
558
  console.log('');
401
559
  console.log('⏳ Menunggu handshake realtime…');
402
560
 
@@ -462,6 +620,22 @@ function runAgentRealtime(rt, opts) {
462
620
 
463
621
  if (msg.type === 'hello.ok') {
464
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
+ }
465
639
  if (!established) {
466
640
  established = true;
467
641
  clearTimeout(guard);
@@ -536,9 +710,9 @@ async function runAgentPolling(rt, opts) {
536
710
  console.log(` Poll : setiap ${intervalSec} detik · heartbeat setiap 30 detik`);
537
711
  console.log(' Realtime : nonaktif — polling HTTP (WS gagal/dimatikan)');
538
712
  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'}`);
713
+ console.log(` Eksekusi : ${execBanner(doExec, execCfg, execCfg.auto)}`);
540
714
  console.log(` State : ${STATE_FILE}`);
541
- console.log(' Hentikan : Ctrl+C');
715
+ console.log(execCfg.auto ? ' Hentikan : luang-cli agent stop (SIGTERM)' : ' Hentikan : Ctrl+C');
542
716
  console.log('');
543
717
 
544
718
  const heartbeat = async (label) => {
@@ -597,10 +771,9 @@ async function runAgentPolling(rt, opts) {
597
771
  if (dirty) saveState(state);
598
772
  ch.latestTasks = tasks;
599
773
 
600
- if (doExec) {
601
- const acted = await tryExecTask(ch, tasks, state, execCfg, current);
602
- if (acted) saveState(state);
603
- }
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);
604
777
 
605
778
  await sleep(intervalSec * 1000);
606
779
  }
@@ -615,24 +788,44 @@ function hbIntervalSec() {
615
788
  }
616
789
 
617
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
+
618
804
  if (!rt.key) return c.errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
619
805
  const flags = args.flags;
620
806
  let intervalSec = Number(flags.interval);
621
807
  if (!intervalSec || intervalSec < 1) intervalSec = 5;
622
808
  if (intervalSec > 3600) intervalSec = 3600;
623
- const doExec = flags.exec === true;
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;
624
813
 
625
- const execCfg = { type: rt.cfg.default_provider, template: null, model: null };
814
+ const execCfg = { type: rt.cfg.default_provider, template: null, model: null, doExec, auto };
626
815
  if (doExec) {
816
+ // Selalu isi dari collectProviders (baik flag --exec maupun mode auto).
627
817
  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;
818
+ if (p) {
819
+ execCfg.template = p.exec_template || null;
820
+ execCfg.model = p.model || null;
821
+ }
630
822
  }
631
823
 
632
824
  const state = loadState();
633
825
  const current = { child: null, ws: null };
634
826
 
635
- process.on('SIGINT', () => {
827
+ // Ctrl+C (SIGINT) dan `agent stop` (SIGTERM) memakai jalur yang sama.
828
+ const shutdown = () => {
636
829
  if (current.child) {
637
830
  try {
638
831
  current.child.kill('SIGINT');
@@ -649,7 +842,9 @@ async function cmdAgent(rt, args) {
649
842
  }
650
843
  console.log('\n👋 Agent dihentikan.');
651
844
  process.exit(0);
652
- });
845
+ };
846
+ process.on('SIGINT', shutdown);
847
+ process.on('SIGTERM', shutdown);
653
848
 
654
849
  // 1) Realtime WebSocket — enhancement, bukan syarat (kontrak §5).
655
850
  if (canRealtime(rt)) {
@@ -671,6 +866,162 @@ async function cmdAgent(rt, args) {
671
866
  return runAgentPolling(rt, { intervalSec, doExec, execCfg, state, current });
672
867
  }
673
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);
1022
+ return 0;
1023
+ }
1024
+
674
1025
  // ── task list / create / status ──────────────────────────────────────────────
675
1026
 
676
1027
  async function cmdTaskList(rt, args) {
@@ -817,8 +1168,21 @@ async function cmdStatus(rt) {
817
1168
  console.log(` ⚠️ /me gagal: ${e.message}`);
818
1169
  console.log(` Telemetry: ${c.telemetryConsentLabel(rt.cfg.telemetry === true, null)}`);
819
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);
1182
+ }
820
1183
  const list = collectProviders(rt.cfg);
821
- 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' : ''}`);
822
1186
  console.log(` Provider lokal : ${list.length ? list.map((p) => (p.is_default ? '★' + p.provider_type : p.provider_type)).join(', ') : '(tidak ada)'}`);
823
1187
  console.log(` Config/state : ${CONFIG_DIR.replace(process.env.HOME || '~', '~')}`);
824
1188
  return 0;