@alashi/cli 0.14.0 → 0.16.0

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/assets/v1/chat.js CHANGED
@@ -5,9 +5,15 @@
5
5
  * <script src="/assets/v1/chat.js"></script>
6
6
  * <alashi-chat></alashi-chat>
7
7
  *
8
- * and get conversations, SSE streaming, markdown rendering, tool-use status,
9
- * and cost display. Renders into light DOM using system.css classes, so it
10
- * inherits the app's theme tokens.
8
+ * and get conversations, live event streaming, markdown rendering, tool-use
9
+ * status, interactive tool-approval cards, and cost display. Renders into
10
+ * light DOM using system.css classes, so it inherits the app's theme tokens.
11
+ *
12
+ * Turns are enqueue-only: POST /chat returns 202 and every event (including
13
+ * the echo of the user's own message) arrives over a standing per-conversation
14
+ * EventSource, so any number of tabs render the same transcript and the
15
+ * stream survives refreshes via Last-Event-ID replay + the pending-approvals
16
+ * snapshot.
11
17
  *
12
18
  * The same script also registers <alashi-markdown>, the markdown renderer on
13
19
  * its own for apps that display prose outside a conversation (see the bottom
@@ -121,6 +127,8 @@
121
127
  if (this._initialized) return;
122
128
  this._initialized = true;
123
129
  this.conversationId = null;
130
+ this._events = null;
131
+ this._resetTurn();
124
132
  this._render();
125
133
  // Single-conversation mode: the embedding app opens the transcript it
126
134
  // wants, so skip both the listing fetch and the auto-open.
@@ -238,6 +246,8 @@
238
246
 
239
247
  async openConversation(id) {
240
248
  this.conversationId = id;
249
+ this._disconnect();
250
+ this._resetTurn();
241
251
  this._log.replaceChildren();
242
252
  this._status.textContent = 'loading history…';
243
253
  const { bubble, note } = this._appendPending();
@@ -245,19 +255,287 @@
245
255
  try {
246
256
  const res = await fetch(this._hostApi(`/chat/${encodeURIComponent(id)}/history`));
247
257
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
248
- const { messages } = await res.json();
258
+ const { messages, approvals = [] } = await res.json();
249
259
  bubble.remove();
250
- for (const message of messages) this._appendMsg(message.text, message.role);
260
+ // Settled approvals interleave by time; transcript messages carry
261
+ // timestamps, approvals carry requestedAt.
262
+ const items = [
263
+ ...messages.map((m) => ({
264
+ at: m.timestamp ?? '',
265
+ render: () => this._appendMsg(m.text, m.role),
266
+ })),
267
+ ...approvals.map((a) => ({
268
+ at: a.requestedAt ?? '',
269
+ render: () => this._renderSettledApproval(a),
270
+ })),
271
+ ].sort((x, y) => (x.at < y.at ? -1 : x.at > y.at ? 1 : 0));
272
+ for (const item of items) item.render();
251
273
  this._status.textContent = '';
252
274
  } catch (err) {
253
275
  this._showError(bubble, false, `Failed to load history: ${String(err)}`);
254
276
  this._status.textContent = String(err);
255
277
  }
278
+ await this._loadPendingApprovals().catch(() => {});
279
+ // Fresh connect: the host replays the in-flight turn (everything before
280
+ // it is already in the history we just rendered), then follows live.
281
+ this._connect(id);
256
282
  this._loadConversations().catch(() => {});
257
283
  this._emit('conversation', { conversationId: id });
258
284
  this._input.focus();
259
285
  }
260
286
 
287
+ /* Standing per-conversation EventSource. `fromStart` replays the whole
288
+ * ring — used right after creating a conversation, whose only events are
289
+ * this first turn. Reconnects resume via Last-Event-ID automatically; a
290
+ * gap beyond the ring arrives as a `resync` event instead. */
291
+ _connect(id, { fromStart = false } = {}) {
292
+ this._disconnect();
293
+ const url = this._hostApi(
294
+ `/chat/${encodeURIComponent(id)}/events${fromStart ? '?lastEventId=0' : ''}`,
295
+ );
296
+ this._events = new EventSource(url);
297
+ this._events.onmessage = (e) => {
298
+ let event;
299
+ try {
300
+ event = JSON.parse(e.data);
301
+ } catch {
302
+ return;
303
+ }
304
+ if (this.conversationId === id) this._handleEvent(event);
305
+ };
306
+ }
307
+
308
+ _disconnect() {
309
+ if (this._events) {
310
+ this._events.close();
311
+ this._events = null;
312
+ }
313
+ }
314
+
315
+ _resetTurn() {
316
+ this._reply = null;
317
+ this._replyNote = null;
318
+ this._replyText = '';
319
+ }
320
+
321
+ /* The current turn's assistant bubble, created on demand so a turn echoed
322
+ * from another tab renders the same as one submitted here. */
323
+ _ensureReply() {
324
+ if (!this._reply?.isConnected) {
325
+ const { bubble, note } = this._appendPending();
326
+ this._reply = bubble;
327
+ this._replyNote = note;
328
+ this._replyText = '';
329
+ }
330
+ return this._reply;
331
+ }
332
+
333
+ _handleEvent(event) {
334
+ if (event.type === 'user_message') {
335
+ this._appendMsg(event.text, 'user');
336
+ this._ensureReply();
337
+ this._status.textContent = 'thinking…';
338
+ return;
339
+ }
340
+ if (event.type === 'text') {
341
+ const reply = this._ensureReply();
342
+ this._replyText += event.text;
343
+ reply.classList.remove('pending');
344
+ this._setMsgText(reply, this._replyText, 'assistant');
345
+ this._log.scrollTop = this._log.scrollHeight;
346
+ return;
347
+ }
348
+ if (event.type === 'tool_use') {
349
+ this._status.textContent = `using tool: ${event.name}`;
350
+ if (this._reply?.classList.contains('pending'))
351
+ this._replyNote.textContent = `using tool: ${event.name}…`;
352
+ return;
353
+ }
354
+ if (event.type === 'approval_required') {
355
+ this._renderApproval(event);
356
+ return;
357
+ }
358
+ if (event.type === 'approval_resolved') {
359
+ this._resolveApproval(event);
360
+ return;
361
+ }
362
+ if (event.type === 'compaction') {
363
+ this._log.append(
364
+ el('div', {
365
+ className: 'chat-divider',
366
+ textContent: `context compacted · ${event.trigger}`,
367
+ }),
368
+ );
369
+ this._log.scrollTop = this._log.scrollHeight;
370
+ return;
371
+ }
372
+ if (event.type === 'resync') {
373
+ // Missed too much to replay: rebuild from history + pending snapshot.
374
+ this.openConversation(this.conversationId).catch(() => {});
375
+ return;
376
+ }
377
+ if (event.type === 'result') {
378
+ this._status.textContent = event.ok
379
+ ? `done${event.costUsd ? ` ($${event.costUsd.toFixed(4)})` : ''}`
380
+ : `error: ${event.error}`;
381
+ const reply = this._ensureReply();
382
+ if (!event.ok) {
383
+ this._showError(reply, this._replyText !== '', `Something went wrong: ${event.error}`);
384
+ } else if (this._replyText === '') {
385
+ reply.className = 'msg assistant';
386
+ reply.replaceChildren(
387
+ el('span', { className: 'typing-note', textContent: '(no reply)' }),
388
+ );
389
+ }
390
+ this._emit('turn', {
391
+ ok: event.ok,
392
+ costUsd: event.costUsd,
393
+ error: event.error,
394
+ conversationId: this.conversationId,
395
+ });
396
+ this._resetTurn();
397
+ this._send.disabled = false;
398
+ this._input.focus();
399
+ }
400
+ }
401
+
402
+ /* Inline approval card: tool, input preview, and the four decisions. The
403
+ * decision travels over the respond endpoint; the card collapses when the
404
+ * matching approval_resolved event comes back on the bus (so every tab
405
+ * collapses, not just the one that clicked). */
406
+ _renderApproval(event) {
407
+ if (this._approvalCard(event.approvalId)) return; // snapshot + replay overlap
408
+ const card = el('div', { className: 'approval-card' });
409
+ card.dataset.approvalId = event.approvalId;
410
+ card.dataset.tool = event.tool;
411
+ let preview = '';
412
+ try {
413
+ preview = JSON.stringify(event.input, null, 2);
414
+ } catch {
415
+ preview = String(event.input);
416
+ }
417
+ if (preview && preview.length > 2000) preview = `${preview.slice(0, 2000)}…`;
418
+ const actions = el('div', { className: 'approval-actions' });
419
+ const act = (label, body, primary) => {
420
+ const button = el('button', {
421
+ type: 'button',
422
+ textContent: label,
423
+ className: primary ? 'primary' : '',
424
+ });
425
+ button.addEventListener('click', () => {
426
+ for (const b of actions.querySelectorAll('button')) b.disabled = true;
427
+ this._respondToApproval(event.approvalId, body).catch(() => {
428
+ for (const b of actions.querySelectorAll('button')) b.disabled = false;
429
+ });
430
+ });
431
+ actions.append(button);
432
+ };
433
+ act('Approve', { behavior: 'allow' }, true);
434
+ act('Approve for session', { behavior: 'allow', scope: 'session' });
435
+ act('Always allow', { behavior: 'allow', scope: 'always' });
436
+ // Grants cover the rule that asked, not the whole tool: say so.
437
+ const always = actions.querySelectorAll('button')[2];
438
+ if (always) {
439
+ always.title = event.reason
440
+ ? `Always allow: ${event.reason}`
441
+ : `Always allow ${event.tool}`;
442
+ }
443
+ act('Deny', { behavior: 'deny' });
444
+ card.append(
445
+ el('div', {
446
+ className: 'approval-title',
447
+ textContent: `Approval required: ${event.tool}`,
448
+ }),
449
+ ...(event.reason
450
+ ? [
451
+ el('div', {
452
+ className: 'approval-reason',
453
+ textContent: `Asked by policy: ${event.reason}`,
454
+ }),
455
+ ]
456
+ : []),
457
+ ...(preview ? [el('pre', {}, [el('code', { textContent: preview })])] : []),
458
+ actions,
459
+ );
460
+ // Keep the typing indicator visually last while the engine is suspended.
461
+ if (this._reply?.isConnected) this._log.insertBefore(card, this._reply);
462
+ else this._log.append(card);
463
+ this._log.scrollTop = this._log.scrollHeight;
464
+ }
465
+
466
+ _approvalCard(approvalId) {
467
+ for (const card of this._log.querySelectorAll('.approval-card')) {
468
+ if (card.dataset.approvalId === approvalId) return card;
469
+ }
470
+ return null;
471
+ }
472
+
473
+ async _respondToApproval(approvalId, body) {
474
+ const res = await fetch(
475
+ this._hostApi(
476
+ `/chat/${encodeURIComponent(this.conversationId)}/approvals/${encodeURIComponent(approvalId)}`,
477
+ ),
478
+ {
479
+ method: 'POST',
480
+ headers: { 'content-type': 'application/json' },
481
+ body: JSON.stringify(body),
482
+ },
483
+ );
484
+ if (!res.ok && res.status !== 409) throw new Error(`HTTP ${res.status}`);
485
+ }
486
+
487
+ _resolveApproval(event) {
488
+ const card = this._approvalCard(event.approvalId);
489
+ if (!card) return;
490
+ const verdict = event.behavior === 'allow' ? 'Approved' : 'Denied';
491
+ const why =
492
+ event.reason && event.reason !== 'user' ? ` · ${event.reason.replace('_', ' ')}` : '';
493
+ card.className = 'approval-card resolved';
494
+ card.replaceChildren(
495
+ el('div', {
496
+ className: 'approval-title',
497
+ textContent: `${verdict}: ${card.dataset.tool}${why}`,
498
+ }),
499
+ );
500
+ }
501
+
502
+ /* A past decision rendered from the approval log (history reload):
503
+ * the same collapsed line a live approval_resolved produces. */
504
+ _renderSettledApproval(record) {
505
+ if (this._approvalCard(record.approvalId)) return;
506
+ const verdict = record.status === 'allowed' ? 'Approved' : 'Denied';
507
+ const why = record.status === 'timeout' ? ' · timeout' : '';
508
+ const card = el('div', { className: 'approval-card resolved' });
509
+ card.dataset.approvalId = record.approvalId;
510
+ card.dataset.tool = record.tool;
511
+ card.append(
512
+ el('div', {
513
+ className: 'approval-title',
514
+ textContent: `${verdict}: ${record.tool}${why}`,
515
+ }),
516
+ );
517
+ this._log.append(card);
518
+ }
519
+
520
+ async _loadPendingApprovals() {
521
+ if (!this.conversationId) return;
522
+ const res = await fetch(
523
+ this._hostApi(`/chat/${encodeURIComponent(this.conversationId)}/pending-approvals`),
524
+ );
525
+ if (!res.ok) return;
526
+ const { approvals } = await res.json();
527
+ for (const approval of approvals) {
528
+ this._renderApproval({
529
+ type: 'approval_required',
530
+ approvalId: approval.approvalId,
531
+ tool: approval.tool,
532
+ input: approval.input,
533
+ ...(approval.ruleId ? { ruleId: approval.ruleId } : {}),
534
+ ...(approval.reason ? { reason: approval.reason } : {}),
535
+ });
536
+ }
537
+ }
538
+
261
539
  async adoptSession(sessionId, title) {
262
540
  const res = await fetch(this._hostApi('/chat/conversations'), {
263
541
  method: 'POST',
@@ -272,6 +550,8 @@
272
550
 
273
551
  newChat() {
274
552
  this.conversationId = null;
553
+ this._disconnect();
554
+ this._resetTurn();
275
555
  this._log.replaceChildren();
276
556
  this._status.textContent = '';
277
557
  this._loadConversations().catch(() => {});
@@ -283,15 +563,14 @@
283
563
  this.dispatchEvent(new CustomEvent(`alashi-chat:${name}`, { detail, bubbles: true }));
284
564
  }
285
565
 
566
+ /* Enqueue-only: the 202 carries the conversation id, and the turn itself
567
+ * (including the echo of this message) arrives over the EventSource. */
286
568
  async _submit() {
287
569
  const message = this._input.value.trim();
288
570
  if (!message) return;
289
571
  this._input.value = '';
290
572
  this._send.disabled = true;
291
- this._appendMsg(message, 'user');
292
- const { bubble: reply, note } = this._appendPending();
293
573
  this._status.textContent = 'thinking…';
294
- let replyText = '';
295
574
 
296
575
  try {
297
576
  const res = await fetch(this._hostApi('/chat'), {
@@ -304,58 +583,20 @@
304
583
  });
305
584
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
306
585
  const isNew = !this.conversationId;
307
- this.conversationId = res.headers.get('x-conversation-id') || this.conversationId;
308
-
309
- const reader = res.body.getReader();
310
- const decoder = new TextDecoder();
311
- let buffer = '';
312
- for (;;) {
313
- const { done, value } = await reader.read();
314
- if (done) break;
315
- buffer += decoder.decode(value, { stream: true });
316
- const frames = buffer.split('\n\n');
317
- buffer = frames.pop();
318
- for (const frame of frames) {
319
- const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
320
- if (!dataLine) continue;
321
- const event = JSON.parse(dataLine.slice(6));
322
- if (event.type === 'text') {
323
- replyText += event.text;
324
- reply.classList.remove('pending');
325
- this._setMsgText(reply, replyText, 'assistant');
326
- this._log.scrollTop = this._log.scrollHeight;
327
- }
328
- if (event.type === 'tool_use') {
329
- this._status.textContent = `using tool: ${event.name}`;
330
- if (reply.classList.contains('pending'))
331
- note.textContent = `using tool: ${event.name}…`;
332
- }
333
- if (event.type === 'result') {
334
- this._status.textContent = event.ok
335
- ? `done${event.costUsd ? ` ($${event.costUsd.toFixed(4)})` : ''}`
336
- : `error: ${event.error}`;
337
- if (!event.ok) {
338
- this._showError(reply, replyText !== '', `Something went wrong: ${event.error}`);
339
- } else if (replyText === '') {
340
- reply.className = 'msg assistant';
341
- reply.replaceChildren(
342
- el('span', { className: 'typing-note', textContent: '(no reply)' }),
343
- );
344
- }
345
- this._emit('turn', {
346
- ok: event.ok,
347
- costUsd: event.costUsd,
348
- error: event.error,
349
- conversationId: this.conversationId,
350
- });
351
- }
352
- }
586
+ const { conversationId } = await res.json();
587
+ this.conversationId = conversationId || this.conversationId;
588
+ if (isNew) {
589
+ // New conversation: nothing on the bus predates this turn, so replay
590
+ // it all — the submit happened before the stream existed.
591
+ this._connect(this.conversationId, { fromStart: true });
592
+ this._emit('conversation', { conversationId: this.conversationId });
593
+ this._loadConversations().catch(() => {});
353
594
  }
354
- if (isNew) this._loadConversations().catch(() => {});
355
595
  } catch (err) {
356
596
  this._status.textContent = String(err);
357
- this._showError(reply, replyText !== '', `Something went wrong: ${String(err)}`);
358
- } finally {
597
+ this._appendMsg(message, 'user');
598
+ const { bubble } = this._appendPending();
599
+ this._showError(bubble, false, `Something went wrong: ${String(err)}`);
359
600
  this._send.disabled = false;
360
601
  this._input.focus();
361
602
  }
@@ -675,3 +675,48 @@ alashi-markdown {
675
675
  color: var(--muted);
676
676
  margin-top: 0.4rem;
677
677
  }
678
+ .approval-card {
679
+ border: 1px solid var(--warn, var(--border));
680
+ border-radius: 8px;
681
+ padding: 0.6rem 0.8rem;
682
+ margin: 0.25rem 0 0.6rem;
683
+ font-size: 0.9rem;
684
+ }
685
+ .approval-card .approval-title {
686
+ font-weight: 600;
687
+ margin-bottom: 0.4rem;
688
+ }
689
+ .approval-card .approval-reason {
690
+ color: var(--muted);
691
+ font-size: 0.8rem;
692
+ margin: -0.2rem 0 0.4rem;
693
+ }
694
+ .approval-card pre {
695
+ max-height: 10rem;
696
+ overflow: auto;
697
+ margin: 0 0 0.5rem;
698
+ font-size: 0.8rem;
699
+ }
700
+ .approval-card.resolved {
701
+ border-color: var(--border);
702
+ color: var(--muted);
703
+ }
704
+ .approval-card.resolved .approval-title {
705
+ font-weight: 400;
706
+ margin-bottom: 0;
707
+ font-size: 0.85rem;
708
+ }
709
+ .approval-actions {
710
+ display: flex;
711
+ flex-wrap: wrap;
712
+ gap: 0.4rem;
713
+ }
714
+ .chat-divider {
715
+ color: var(--muted);
716
+ font-size: 0.75rem;
717
+ border-top: 1px dashed var(--border);
718
+ padding-top: 0.25rem;
719
+ margin: 0.4rem auto;
720
+ width: 60%;
721
+ text-align: center;
722
+ }
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { A as UsageRecorder, E as budgetStatus, N as AuthService, O as setAppBudget, b as loadApp, c as installApp, d as updateApp, f as addRegistry, h as removeRegistry, i as resolveAppConfig, n as createServer, o as exportApp, s as importApp, t as Store, u as removeApp } from "./store-DoAGOynU.mjs";
2
+ import { A as UsageRecorder, E as budgetStatus, N as AuthService, O as setAppBudget, b as loadApp, c as installApp, d as updateApp, f as addRegistry, h as removeRegistry, i as resolveAppConfig, n as createServer, o as exportApp, s as importApp, t as Store, u as removeApp } from "./store-DvLX0Kft.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { APP_NAME_PATTERN, AppSdkError } from "@alashi/apps-sdk";
5
5
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";