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/README.md +150 -21
- package/bin/luang-cli.js +118 -19
- package/lib/agent.js +869 -81
- package/lib/commands.js +225 -3
- package/lib/core.js +38 -0
- package/lib/models.js +343 -0
- package/lib/ws.js +552 -0
- package/package.json +4 -1
- package/test/bin/opencode +10 -0
- package/test/http-stub.js +77 -0
- package/test/run-e2e.js +806 -0
- package/test/ws-stub.js +202 -0
package/lib/agent.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// lib/agent.js — daemon agent
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
171
|
+
if (t.includes('"{task}"')) {
|
|
54
172
|
q = taskText.replace(/(["`\\$])/g, '\\$1'); // escape untuk kutip ganda
|
|
55
|
-
} else if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
if (!
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
155
|
-
const current = { child: null };
|
|
156
|
-
let running = true;
|
|
539
|
+
// ── Mode realtime (WS) ────────────────────────────────────────────────────────
|
|
157
540
|
|
|
158
|
-
|
|
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(`
|
|
161
|
-
console.log(` Eksekusi : ${doExec
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
585
|
+
ws.send({ type: 'hello', ts: Date.now(), data: { mode: 'cli', version: pkg.version, providers: providersForHello(rt.cfg) } });
|
|
171
586
|
} catch (e) {
|
|
172
|
-
|
|
587
|
+
console.error(`⚠️ Gagal kirim hello: ${e.message}`);
|
|
173
588
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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:
|
|
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
|
-
|
|
729
|
+
let hbTimer = setInterval(() => {
|
|
191
730
|
if (running) heartbeat('Heartbeat');
|
|
192
|
-
},
|
|
731
|
+
}, hbIntervalSec() * 1000);
|
|
193
732
|
|
|
194
733
|
let first = true;
|
|
195
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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(
|
|
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
|
-
|
|
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 };
|