lunel-cli 0.1.57 → 0.1.58

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.
@@ -43,6 +43,8 @@ export declare class CodexProvider implements AIProvider {
43
43
  share: ShareInfo;
44
44
  }>;
45
45
  permissionReply(sessionId: string, permissionId: string, response: "once" | "always" | "reject"): Promise<Record<string, never>>;
46
+ questionReply(): Promise<Record<string, never>>;
47
+ questionReject(): Promise<Record<string, never>>;
46
48
  private send;
47
49
  private call;
48
50
  private handleLine;
package/dist/ai/codex.js CHANGED
@@ -209,6 +209,12 @@ export class CodexProvider {
209
209
  });
210
210
  return {};
211
211
  }
212
+ async questionReply() {
213
+ throw new Error("Codex structured user input is not supported by Lunel yet");
214
+ }
215
+ async questionReject() {
216
+ throw new Error("Codex structured user input is not supported by Lunel yet");
217
+ }
212
218
  send(req) {
213
219
  if (!this.proc?.stdin?.writable)
214
220
  return;
@@ -42,6 +42,8 @@ export declare class AiManager {
42
42
  share: import("./interface.js").ShareInfo;
43
43
  }>;
44
44
  permissionReply(backend: AiBackend, sessionId: string, permissionId: string, response: "once" | "always" | "reject"): Promise<Record<string, never>>;
45
+ questionReply(backend: AiBackend, sessionId: string, questionId: string, answers: string[][]): Promise<Record<string, never>>;
46
+ questionReject(backend: AiBackend, sessionId: string, questionId: string): Promise<Record<string, never>>;
45
47
  }
46
48
  export declare function createAiManager(): Promise<AiManager>;
47
49
  export type { AIProvider, AiEventEmitter, AiEvent, ModelSelector } from "./interface.js";
package/dist/ai/index.js CHANGED
@@ -84,6 +84,20 @@ export class AiManager {
84
84
  permissionReply(backend, sessionId, permissionId, response) {
85
85
  return this.get(backend).permissionReply(sessionId, permissionId, response);
86
86
  }
87
+ questionReply(backend, sessionId, questionId, answers) {
88
+ const provider = this.get(backend);
89
+ if (!provider.questionReply) {
90
+ throw new Error(`Backend "${backend}" does not support question replies`);
91
+ }
92
+ return provider.questionReply(sessionId, questionId, answers);
93
+ }
94
+ questionReject(backend, sessionId, questionId) {
95
+ const provider = this.get(backend);
96
+ if (!provider.questionReject) {
97
+ throw new Error(`Backend "${backend}" does not support question rejection`);
98
+ }
99
+ return provider.questionReject(sessionId, questionId);
100
+ }
87
101
  }
88
102
  export async function createAiManager() {
89
103
  const manager = new AiManager();
@@ -69,4 +69,6 @@ export interface AIProvider {
69
69
  share: ShareInfo;
70
70
  }>;
71
71
  permissionReply(sessionId: string, permissionId: string, response: "once" | "always" | "reject"): Promise<Record<string, never>>;
72
+ questionReply?(sessionId: string, questionId: string, answers: string[][]): Promise<Record<string, never>>;
73
+ questionReject?(sessionId: string, questionId: string): Promise<Record<string, never>>;
72
74
  }
@@ -2,9 +2,12 @@ import type { AIProvider, AiEventEmitter, ModelSelector, MessageInfo, ProviderIn
2
2
  export declare class OpenCodeProvider implements AIProvider {
3
3
  private client;
4
4
  private server;
5
+ private authHeader;
5
6
  private lastActiveSessionId;
6
7
  private shuttingDown;
7
8
  private emitter;
9
+ private knownPendingPermissionIds;
10
+ private knownPendingQuestionIds;
8
11
  init(): Promise<void>;
9
12
  destroy(): Promise<void>;
10
13
  subscribe(emitter: AiEventEmitter): () => void;
@@ -40,5 +43,16 @@ export declare class OpenCodeProvider implements AIProvider {
40
43
  share: ShareInfo;
41
44
  }>;
42
45
  permissionReply(sessionId: string, permissionId: string, response: "once" | "always" | "reject"): Promise<Record<string, never>>;
46
+ questionReply(sessionId: string, questionId: string, answers: string[][]): Promise<Record<string, never>>;
47
+ questionReject(sessionId: string, questionId: string): Promise<Record<string, never>>;
43
48
  private runSseLoop;
49
+ private sendPromptAsync;
50
+ private reconcileOpenCodeState;
51
+ private refreshSessionsMetadata;
52
+ private refreshPendingPermissions;
53
+ private refreshPendingQuestions;
54
+ private refreshSessionStatuses;
55
+ private trackPermissionEvent;
56
+ private asRecord;
57
+ private readString;
44
58
  }
@@ -26,15 +26,19 @@ function requireData(response, label) {
26
26
  export class OpenCodeProvider {
27
27
  client = null;
28
28
  server = null;
29
+ authHeader = null;
29
30
  lastActiveSessionId = null;
30
31
  shuttingDown = false;
31
32
  emitter = null;
33
+ knownPendingPermissionIds = new Set();
34
+ knownPendingQuestionIds = new Set();
32
35
  async init() {
33
36
  const opencodeUsername = "lunel";
34
37
  const opencodePassword = crypto.randomBytes(32).toString("base64url");
35
38
  const authHeader = `Basic ${Buffer.from(`${opencodeUsername}:${opencodePassword}`).toString("base64")}`;
36
39
  process.env.OPENCODE_SERVER_USERNAME = opencodeUsername;
37
40
  process.env.OPENCODE_SERVER_PASSWORD = opencodePassword;
41
+ this.authHeader = authHeader;
38
42
  console.log("Starting OpenCode...");
39
43
  this.server = await createOpencodeServer({
40
44
  hostname: "127.0.0.1",
@@ -50,6 +54,7 @@ export class OpenCodeProvider {
50
54
  }
51
55
  async destroy() {
52
56
  this.shuttingDown = true;
57
+ this.authHeader = null;
53
58
  }
54
59
  subscribe(emitter) {
55
60
  this.emitter = emitter;
@@ -146,14 +151,9 @@ export class OpenCodeProvider {
146
151
  });
147
152
  }
148
153
  // Fire-and-forget — results come back through the SSE event stream.
149
- this.client.session.prompt({
150
- path: { id: sessionId },
151
- body: {
152
- parts: [{ type: "text", text }],
153
- ...(model ? { model } : {}),
154
- ...(agent ? { agent } : {}),
155
- },
156
- }).catch((err) => {
154
+ // Prefer the async prompt endpoint so long-running turns do not get tied
155
+ // to the request lifecycle the way the basic prompt route can be.
156
+ this.sendPromptAsync(sessionId, text, model, agent).catch((err) => {
157
157
  console.error("[ai] prompt error:", err.message);
158
158
  this.emitter?.({
159
159
  type: "prompt_error",
@@ -243,6 +243,26 @@ export class OpenCodeProvider {
243
243
  });
244
244
  return {};
245
245
  }
246
+ async questionReply(sessionId, questionId, answers) {
247
+ const questionApi = this.client?.question;
248
+ if (!questionApi?.reply) {
249
+ throw new Error("OpenCode question replies are not available in this runtime");
250
+ }
251
+ await questionApi.reply({ requestID: questionId, answers });
252
+ this.knownPendingQuestionIds.delete(questionId);
253
+ this.emitter?.({ type: "question.replied", properties: { sessionID: sessionId, requestID: questionId, answers } });
254
+ return {};
255
+ }
256
+ async questionReject(sessionId, questionId) {
257
+ const questionApi = this.client?.question;
258
+ if (!questionApi?.reject) {
259
+ throw new Error("OpenCode question rejection is not available in this runtime");
260
+ }
261
+ await questionApi.reject({ requestID: questionId });
262
+ this.knownPendingQuestionIds.delete(questionId);
263
+ this.emitter?.({ type: "question.rejected", properties: { sessionID: sessionId, requestID: questionId } });
264
+ return {};
265
+ }
246
266
  // -------------------------------------------------------------------------
247
267
  // SSE event loop (private)
248
268
  // -------------------------------------------------------------------------
@@ -270,6 +290,9 @@ export class OpenCodeProvider {
270
290
  console.log(`[sse] Active session ${this.lastActiveSessionId} still valid.`);
271
291
  }
272
292
  }
293
+ if (attempt > 0) {
294
+ await this.reconcileOpenCodeState();
295
+ }
273
296
  const events = await this.client.event.subscribe();
274
297
  if (attempt > 0) {
275
298
  console.log(`[sse] reconnected after ${attempt} attempt(s)`);
@@ -290,6 +313,7 @@ export class OpenCodeProvider {
290
313
  continue;
291
314
  }
292
315
  console.log("[sse]", base.type);
316
+ this.trackPermissionEvent(base.type, base.properties || {});
293
317
  this.emitter?.({ type: base.type, properties: base.properties || {} });
294
318
  }
295
319
  console.log("[sse] Event stream ended, reconnecting...");
@@ -312,4 +336,203 @@ export class OpenCodeProvider {
312
336
  }
313
337
  }
314
338
  }
339
+ async sendPromptAsync(sessionId, text, model, agent) {
340
+ const server = this.server;
341
+ const authHeader = this.authHeader;
342
+ if (!server || !authHeader) {
343
+ throw new Error("OpenCode server is not ready");
344
+ }
345
+ const url = new URL(`/session/${encodeURIComponent(sessionId)}/prompt_async`, server.url);
346
+ const response = await fetch(url, {
347
+ method: "POST",
348
+ headers: {
349
+ Authorization: authHeader,
350
+ "content-type": "application/json",
351
+ accept: "application/json",
352
+ },
353
+ body: JSON.stringify({
354
+ parts: [{ type: "text", text }],
355
+ ...(model ? { model } : {}),
356
+ ...(agent ? { agent } : {}),
357
+ }),
358
+ });
359
+ if (!response.ok) {
360
+ let detail = "";
361
+ try {
362
+ detail = await response.text();
363
+ }
364
+ catch {
365
+ // ignore detail read failures
366
+ }
367
+ const suffix = detail.trim().length > 0 ? `: ${detail.trim()}` : "";
368
+ throw new Error(`OpenCode prompt_async failed (${response.status})${suffix}`);
369
+ }
370
+ }
371
+ async reconcileOpenCodeState() {
372
+ await Promise.allSettled([
373
+ this.refreshSessionsMetadata(),
374
+ this.refreshPendingPermissions(),
375
+ this.refreshPendingQuestions(),
376
+ this.refreshSessionStatuses(),
377
+ ]);
378
+ }
379
+ async refreshSessionsMetadata() {
380
+ const response = await this.client.session.list();
381
+ const sessions = Array.isArray(response.data) ? response.data : [];
382
+ for (const session of sessions) {
383
+ const info = this.asRecord(session);
384
+ const id = this.readString(info.id);
385
+ if (!id)
386
+ continue;
387
+ this.emitter?.({
388
+ type: "session.updated",
389
+ properties: { info },
390
+ });
391
+ }
392
+ }
393
+ async refreshPendingPermissions() {
394
+ const permissionApi = this.client?.permission;
395
+ if (!permissionApi?.list) {
396
+ return;
397
+ }
398
+ const response = await permissionApi.list();
399
+ const data = Array.isArray(response.data) ? response.data : [];
400
+ const nextIds = new Set();
401
+ for (const entry of data) {
402
+ const permission = this.asRecord(entry);
403
+ const id = this.readString(permission.id);
404
+ if (!id)
405
+ continue;
406
+ nextIds.add(id);
407
+ if (this.knownPendingPermissionIds.has(id)) {
408
+ continue;
409
+ }
410
+ this.knownPendingPermissionIds.add(id);
411
+ this.emitter?.({
412
+ type: "permission.updated",
413
+ properties: {
414
+ id,
415
+ sessionID: this.readString(permission.sessionID) ?? this.readString(permission.sessionId),
416
+ messageID: this.readString(this.asRecord(permission.tool).messageID),
417
+ callID: this.readString(this.asRecord(permission.tool).callID),
418
+ type: this.readString(permission.permission) ?? "permission",
419
+ title: this.readString(permission.title)
420
+ ?? this.readString(permission.permission)
421
+ ?? "Permission requested",
422
+ metadata: permission.metadata && typeof permission.metadata === "object"
423
+ ? permission.metadata
424
+ : permission,
425
+ },
426
+ });
427
+ }
428
+ for (const id of Array.from(this.knownPendingPermissionIds)) {
429
+ if (nextIds.has(id))
430
+ continue;
431
+ this.knownPendingPermissionIds.delete(id);
432
+ this.emitter?.({ type: "permission.replied", properties: { permissionId: id } });
433
+ }
434
+ }
435
+ async refreshPendingQuestions() {
436
+ const questionApi = this.client?.question;
437
+ if (!questionApi?.list) {
438
+ return;
439
+ }
440
+ const response = await questionApi.list();
441
+ const data = Array.isArray(response.data) ? response.data : [];
442
+ const nextIds = new Set();
443
+ for (const entry of data) {
444
+ const question = this.asRecord(entry);
445
+ const id = this.readString(question.id);
446
+ const sessionID = this.readString(question.sessionID) ?? this.readString(question.sessionId);
447
+ if (!id || !sessionID)
448
+ continue;
449
+ nextIds.add(id);
450
+ if (this.knownPendingQuestionIds.has(id)) {
451
+ continue;
452
+ }
453
+ this.knownPendingQuestionIds.add(id);
454
+ this.emitter?.({
455
+ type: "question.asked",
456
+ properties: {
457
+ id,
458
+ sessionID,
459
+ questions: Array.isArray(question.questions) ? question.questions : [],
460
+ tool: typeof question.tool === "object" && question.tool !== null ? question.tool : undefined,
461
+ },
462
+ });
463
+ }
464
+ for (const id of Array.from(this.knownPendingQuestionIds)) {
465
+ if (nextIds.has(id))
466
+ continue;
467
+ this.knownPendingQuestionIds.delete(id);
468
+ }
469
+ }
470
+ async refreshSessionStatuses() {
471
+ const server = this.server;
472
+ const authHeader = this.authHeader;
473
+ if (!server || !authHeader) {
474
+ return;
475
+ }
476
+ const url = new URL("/session/status", server.url);
477
+ const response = await fetch(url, {
478
+ headers: {
479
+ Authorization: authHeader,
480
+ accept: "application/json",
481
+ },
482
+ });
483
+ if (!response.ok) {
484
+ return;
485
+ }
486
+ const payload = await response.json().catch(() => null);
487
+ if (!payload || typeof payload !== "object") {
488
+ return;
489
+ }
490
+ for (const [sessionId, status] of Object.entries(payload)) {
491
+ this.emitter?.({
492
+ type: "session.status",
493
+ properties: {
494
+ sessionID: sessionId,
495
+ status: status,
496
+ },
497
+ });
498
+ }
499
+ }
500
+ trackPermissionEvent(type, properties) {
501
+ if (type === "permission.updated") {
502
+ const id = this.readString(properties.id);
503
+ if (id) {
504
+ this.knownPendingPermissionIds.add(id);
505
+ }
506
+ return;
507
+ }
508
+ if (type === "permission.replied") {
509
+ const id = this.readString(properties.permissionId)
510
+ ?? this.readString(properties.requestID)
511
+ ?? this.readString(properties.id);
512
+ if (id) {
513
+ this.knownPendingPermissionIds.delete(id);
514
+ }
515
+ }
516
+ if (type === "question.asked") {
517
+ const id = this.readString(properties.id);
518
+ if (id) {
519
+ this.knownPendingQuestionIds.add(id);
520
+ }
521
+ return;
522
+ }
523
+ if (type === "question.replied" || type === "question.rejected") {
524
+ const id = this.readString(properties.requestID)
525
+ ?? this.readString(properties.questionId)
526
+ ?? this.readString(properties.id);
527
+ if (id) {
528
+ this.knownPendingQuestionIds.delete(id);
529
+ }
530
+ }
531
+ }
532
+ asRecord(value) {
533
+ return value && typeof value === "object" ? value : {};
534
+ }
535
+ readString(value) {
536
+ return typeof value === "string" && value.length > 0 ? value : undefined;
537
+ }
315
538
  }
package/dist/index.js CHANGED
@@ -2410,6 +2410,12 @@ async function processMessage(message) {
2410
2410
  result = await aiManager.permissionReply(backend, payload.sessionId, payload.permissionId, permResp);
2411
2411
  break;
2412
2412
  }
2413
+ case "questionReply":
2414
+ result = await aiManager.questionReply(backend, payload.sessionId, payload.questionId, payload.answers || []);
2415
+ break;
2416
+ case "questionReject":
2417
+ result = await aiManager.questionReject(backend, payload.sessionId, payload.questionId);
2418
+ break;
2413
2419
  default:
2414
2420
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
2415
2421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",