luang-cli 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -5
- package/bin/luang-cli.js +72 -3
- package/lib/agent.js +386 -22
- package/lib/commands.js +157 -1
- package/lib/core.js +30 -0
- package/lib/models.js +343 -0
- package/package.json +2 -2
- package/test/bin/opencode +5 -0
- package/test/http-stub.js +39 -1
- package/test/run-e2e.js +349 -1
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
|
629
|
-
|
|
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
|
-
|
|
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;
|