@poncho-ai/cli 0.18.0 → 0.19.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.
@@ -164,6 +164,62 @@ export const getWebUiClientScript = (markedSource: string): string => `
164
164
  }
165
165
  };
166
166
 
167
+ // During streaming, incomplete backtick sequences cause marked to
168
+ // swallow all subsequent text into an invisible code element. This
169
+ // helper detects unclosed fences and inline code delimiters and
170
+ // appends the missing closing so marked can render partial text.
171
+ const closeStreamingMarkdown = (text) => {
172
+ const BT = "\\x60";
173
+ let result = text;
174
+
175
+ // 1. Unclosed fenced code blocks (lines starting with 3+ backticks)
176
+ const lines = result.split("\\n");
177
+ let openFenceLen = 0;
178
+ for (let li = 0; li < lines.length; li++) {
179
+ const trimmed = lines[li].trimStart();
180
+ let btCount = 0;
181
+ while (btCount < trimmed.length && trimmed[btCount] === BT) btCount++;
182
+ if (btCount >= 3) {
183
+ if (openFenceLen === 0) {
184
+ openFenceLen = btCount;
185
+ } else if (btCount >= openFenceLen) {
186
+ openFenceLen = 0;
187
+ }
188
+ }
189
+ }
190
+ if (openFenceLen > 0) {
191
+ let fence = "";
192
+ for (let k = 0; k < openFenceLen; k++) fence += BT;
193
+ return result + "\\n" + fence;
194
+ }
195
+
196
+ // 2. Unclosed inline code delimiters
197
+ let idx = 0;
198
+ let inCode = false;
199
+ let delimLen = 0;
200
+ while (idx < result.length) {
201
+ if (result[idx] === BT) {
202
+ let run = 0;
203
+ while (idx < result.length && result[idx] === BT) { run++; idx++; }
204
+ if (!inCode) {
205
+ inCode = true;
206
+ delimLen = run;
207
+ } else if (run === delimLen) {
208
+ inCode = false;
209
+ }
210
+ } else {
211
+ idx++;
212
+ }
213
+ }
214
+ if (inCode) {
215
+ let closing = "";
216
+ for (let k = 0; k < delimLen; k++) closing += BT;
217
+ result += closing;
218
+ }
219
+
220
+ return result;
221
+ };
222
+
167
223
  const extractToolActivity = (value) => {
168
224
  const source = String(value || "");
169
225
  let markerIndex = source.lastIndexOf("\\n### Tool activity\\n");
@@ -230,8 +286,15 @@ export const getWebUiClientScript = (markedSource: string): string => `
230
286
  );
231
287
  })
232
288
  .join("");
289
+ const batchButtons = requests.length > 1
290
+ ? '<div class="approval-batch-actions">' +
291
+ '<button class="approval-batch-btn approve" data-approval-batch="approve">Approve all (' + requests.length + ')</button>' +
292
+ '<button class="approval-batch-btn deny" data-approval-batch="deny">Deny all (' + requests.length + ')</button>' +
293
+ "</div>"
294
+ : "";
233
295
  return (
234
296
  '<div class="approval-requests">' +
297
+ batchButtons +
235
298
  rows +
236
299
  "</div>"
237
300
  );
@@ -761,7 +824,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
761
824
  // Show current text being typed
762
825
  if (isStreaming && i === messages.length - 1 && m._currentText) {
763
826
  const textDiv = document.createElement("div");
764
- textDiv.innerHTML = renderAssistantMarkdown(m._currentText);
827
+ textDiv.innerHTML = renderAssistantMarkdown(closeStreamingMarkdown(m._currentText));
765
828
  content.appendChild(textDiv);
766
829
  }
767
830
  } else {
@@ -2343,45 +2406,20 @@ export const getWebUiClientScript = (markedSource: string): string => `
2343
2406
  openLightbox(img.src);
2344
2407
  });
2345
2408
 
2346
- elements.messages.addEventListener("click", async (event) => {
2347
- const target = event.target;
2348
- if (!(target instanceof Element)) {
2349
- return;
2350
- }
2351
- const button = target.closest(".approval-action-btn");
2352
- if (!button) {
2353
- return;
2354
- }
2355
- const approvalId = button.getAttribute("data-approval-id") || "";
2356
- const decision = button.getAttribute("data-approval-decision") || "";
2357
- if (!approvalId || (decision !== "approve" && decision !== "deny")) {
2358
- return;
2359
- }
2360
- if (state.approvalRequestsInFlight[approvalId]) {
2361
- return;
2362
- }
2409
+ const submitApproval = async (approvalId, decision, opts) => {
2410
+ const wasStreaming = opts && opts.wasStreaming;
2363
2411
  state.approvalRequestsInFlight[approvalId] = true;
2364
- const wasStreaming = state.isStreaming;
2365
- if (!wasStreaming) {
2366
- setStreaming(true);
2367
- }
2368
2412
  updatePendingApproval(approvalId, (request) => ({
2369
2413
  ...request,
2370
2414
  state: "submitting",
2371
2415
  pendingDecision: decision,
2372
2416
  }));
2373
- renderMessages(state.activeMessages, state.isStreaming);
2374
2417
  try {
2375
2418
  await api("/api/approvals/" + encodeURIComponent(approvalId), {
2376
2419
  method: "POST",
2377
2420
  body: JSON.stringify({ approved: decision === "approve" }),
2378
2421
  });
2379
2422
  updatePendingApproval(approvalId, () => null);
2380
- renderMessages(state.activeMessages, state.isStreaming);
2381
- loadConversations();
2382
- if (!wasStreaming && state.activeConversationId) {
2383
- await streamConversationEvents(state.activeConversationId, { liveOnly: true });
2384
- }
2385
2423
  } catch (error) {
2386
2424
  const isStale = error && error.payload && error.payload.code === "APPROVAL_NOT_FOUND";
2387
2425
  if (isStale) {
@@ -2395,13 +2433,77 @@ export const getWebUiClientScript = (markedSource: string): string => `
2395
2433
  _error: errMsg,
2396
2434
  }));
2397
2435
  }
2398
- renderMessages(state.activeMessages, state.isStreaming);
2399
2436
  } finally {
2437
+ delete state.approvalRequestsInFlight[approvalId];
2438
+ }
2439
+ };
2440
+
2441
+ elements.messages.addEventListener("click", async (event) => {
2442
+ const target = event.target;
2443
+ if (!(target instanceof Element)) {
2444
+ return;
2445
+ }
2446
+
2447
+ // Batch approve/deny all
2448
+ const batchBtn = target.closest(".approval-batch-btn");
2449
+ if (batchBtn) {
2450
+ const decision = batchBtn.getAttribute("data-approval-batch") || "";
2451
+ if (decision !== "approve" && decision !== "deny") return;
2452
+ const messages = state.activeMessages || [];
2453
+ const pending = [];
2454
+ for (const m of messages) {
2455
+ if (Array.isArray(m._pendingApprovals)) {
2456
+ for (const req of m._pendingApprovals) {
2457
+ if (req.approvalId && req.state !== "submitting" && !state.approvalRequestsInFlight[req.approvalId]) {
2458
+ pending.push(req.approvalId);
2459
+ }
2460
+ }
2461
+ }
2462
+ }
2463
+ if (pending.length === 0) return;
2464
+ const wasStreaming = state.isStreaming;
2465
+ if (!wasStreaming) setStreaming(true);
2466
+ renderMessages(state.activeMessages, state.isStreaming);
2467
+ await Promise.all(pending.map((aid) => submitApproval(aid, decision, { wasStreaming })));
2468
+ renderMessages(state.activeMessages, state.isStreaming);
2469
+ loadConversations();
2470
+ if (!wasStreaming && state.activeConversationId) {
2471
+ await streamConversationEvents(state.activeConversationId, { liveOnly: true });
2472
+ }
2400
2473
  if (!wasStreaming) {
2401
2474
  setStreaming(false);
2402
2475
  renderMessages(state.activeMessages, false);
2403
2476
  }
2404
- delete state.approvalRequestsInFlight[approvalId];
2477
+ return;
2478
+ }
2479
+
2480
+ // Individual approve/deny
2481
+ const button = target.closest(".approval-action-btn");
2482
+ if (!button) {
2483
+ return;
2484
+ }
2485
+ const approvalId = button.getAttribute("data-approval-id") || "";
2486
+ const decision = button.getAttribute("data-approval-decision") || "";
2487
+ if (!approvalId || (decision !== "approve" && decision !== "deny")) {
2488
+ return;
2489
+ }
2490
+ if (state.approvalRequestsInFlight[approvalId]) {
2491
+ return;
2492
+ }
2493
+ const wasStreaming = state.isStreaming;
2494
+ if (!wasStreaming) {
2495
+ setStreaming(true);
2496
+ }
2497
+ renderMessages(state.activeMessages, state.isStreaming);
2498
+ await submitApproval(approvalId, decision, { wasStreaming });
2499
+ renderMessages(state.activeMessages, state.isStreaming);
2500
+ loadConversations();
2501
+ if (!wasStreaming && state.activeConversationId) {
2502
+ await streamConversationEvents(state.activeConversationId, { liveOnly: true });
2503
+ }
2504
+ if (!wasStreaming) {
2505
+ setStreaming(false);
2506
+ renderMessages(state.activeMessages, false);
2405
2507
  }
2406
2508
  });
2407
2509
 
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
@@ -165,11 +165,16 @@ type SessionRecord = {
165
165
  export class SessionStore {
166
166
  private readonly sessions = new Map<string, SessionRecord>();
167
167
  private readonly ttlMs: number;
168
+ private signingKey: string | undefined;
168
169
 
169
170
  constructor(ttlMs = 1000 * 60 * 60 * 8) {
170
171
  this.ttlMs = ttlMs;
171
172
  }
172
173
 
174
+ setSigningKey(key: string): void {
175
+ if (key) this.signingKey = key;
176
+ }
177
+
173
178
  create(ownerId = DEFAULT_OWNER): SessionRecord {
174
179
  const now = Date.now();
175
180
  const session: SessionRecord = {
@@ -200,6 +205,68 @@ export class SessionStore {
200
205
  delete(sessionId: string): void {
201
206
  this.sessions.delete(sessionId);
202
207
  }
208
+
209
+ /**
210
+ * Encode a session into a signed cookie value that survives serverless
211
+ * cold starts. Format: `base64url(payload).signature`
212
+ */
213
+ signSession(session: SessionRecord): string | undefined {
214
+ if (!this.signingKey) return undefined;
215
+ const payload = Buffer.from(
216
+ JSON.stringify({
217
+ sid: session.sessionId,
218
+ o: session.ownerId,
219
+ csrf: session.csrfToken,
220
+ exp: session.expiresAt,
221
+ }),
222
+ ).toString("base64url");
223
+ const sig = createHmac("sha256", this.signingKey)
224
+ .update(payload)
225
+ .digest("base64url");
226
+ return `${payload}.${sig}`;
227
+ }
228
+
229
+ /**
230
+ * Restore a session from a signed cookie value. Returns the session
231
+ * (also added to the in-memory store) or undefined if invalid/expired.
232
+ */
233
+ restoreFromSigned(cookieValue: string): SessionRecord | undefined {
234
+ if (!this.signingKey) return undefined;
235
+ const dotIdx = cookieValue.lastIndexOf(".");
236
+ if (dotIdx <= 0) return undefined;
237
+
238
+ const payload = cookieValue.slice(0, dotIdx);
239
+ const sig = cookieValue.slice(dotIdx + 1);
240
+ const expected = createHmac("sha256", this.signingKey)
241
+ .update(payload)
242
+ .digest("base64url");
243
+ if (sig.length !== expected.length) return undefined;
244
+ if (
245
+ !timingSafeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))
246
+ )
247
+ return undefined;
248
+
249
+ try {
250
+ const data = JSON.parse(
251
+ Buffer.from(payload, "base64url").toString("utf8"),
252
+ ) as { sid?: string; o?: string; csrf?: string; exp?: number };
253
+ if (!data.sid || !data.o || !data.csrf || !data.exp) return undefined;
254
+ if (Date.now() > data.exp) return undefined;
255
+
256
+ const session: SessionRecord = {
257
+ sessionId: data.sid,
258
+ ownerId: data.o,
259
+ csrfToken: data.csrf,
260
+ createdAt: data.exp - this.ttlMs,
261
+ expiresAt: data.exp,
262
+ lastSeenAt: Date.now(),
263
+ };
264
+ this.sessions.set(session.sessionId, session);
265
+ return session;
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }
203
270
  }
204
271
 
205
272
  type LoginAttemptState = {
@@ -270,6 +270,22 @@ export const WEB_UI_STYLES = `
270
270
  flex-direction: column;
271
271
  padding: 12px 8px;
272
272
  }
273
+ .sidebar-header {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 8px;
277
+ }
278
+ .sidebar-agent-name {
279
+ font-size: 14px;
280
+ font-weight: 500;
281
+ color: var(--fg-strong);
282
+ flex: 1;
283
+ min-width: 0;
284
+ overflow: hidden;
285
+ text-overflow: ellipsis;
286
+ white-space: nowrap;
287
+ padding-left: 10px;
288
+ }
273
289
  .new-chat-btn {
274
290
  background: transparent;
275
291
  border: 0;
@@ -282,6 +298,7 @@ export const WEB_UI_STYLES = `
282
298
  gap: 8px;
283
299
  font-size: 13px;
284
300
  cursor: pointer;
301
+ flex-shrink: 0;
285
302
  transition: background 0.15s, color 0.15s;
286
303
  }
287
304
  .new-chat-btn:hover { color: var(--fg); }
@@ -784,6 +801,30 @@ export const WEB_UI_STYLES = `
784
801
  opacity: 0.55;
785
802
  cursor: not-allowed;
786
803
  }
804
+ .approval-batch-actions {
805
+ display: flex;
806
+ gap: 6px;
807
+ margin-bottom: 8px;
808
+ }
809
+ .approval-batch-btn {
810
+ border-radius: 6px;
811
+ border: 1px solid var(--border-5);
812
+ background: var(--surface-4);
813
+ color: var(--fg-approval-btn);
814
+ font-size: 11px;
815
+ font-weight: 600;
816
+ padding: 4px 10px;
817
+ cursor: pointer;
818
+ }
819
+ .approval-batch-btn:hover { background: var(--surface-7); }
820
+ .approval-batch-btn.approve {
821
+ border-color: var(--approve-border);
822
+ color: var(--approve);
823
+ }
824
+ .approval-batch-btn.deny {
825
+ border-color: var(--deny-border);
826
+ color: var(--deny);
827
+ }
787
828
  .user-bubble {
788
829
  background: var(--bg-elevated);
789
830
  border: 1px solid var(--border-2);
@@ -1180,6 +1221,9 @@ export const WEB_UI_STYLES = `
1180
1221
  .shell.sidebar-open .sidebar { transform: translateX(0); }
1181
1222
  .sidebar-toggle { display: grid; place-items: center; }
1182
1223
  .topbar-new-chat { display: grid; place-items: center; }
1224
+ .sidebar-header { padding-right: 130px; }
1225
+ .sidebar-agent-name { padding-left: 0; }
1226
+ .new-chat-btn { order: -1; }
1183
1227
  .poncho-badge {
1184
1228
  display: none;
1185
1229
  position: fixed;
@@ -1319,15 +1363,17 @@ export const WEB_UI_STYLES = `
1319
1363
  font-size: 13px;
1320
1364
  }
1321
1365
  @media (max-width: 768px) {
1366
+ .main-body { flex-direction: column; }
1322
1367
  .browser-panel {
1323
- position: fixed;
1324
- inset: 0;
1325
- width: 100% !important;
1368
+ position: relative;
1369
+ order: -1;
1370
+ max-height: 35vh;
1326
1371
  flex: none !important;
1327
- z-index: 200;
1372
+ width: auto !important;
1373
+ border-bottom: 1px solid var(--border-1);
1328
1374
  }
1329
1375
  .browser-panel-resize { display: none !important; }
1330
- .main-chat.has-browser { flex: 1 1 auto !important; min-width: 0; }
1376
+ .main-chat.has-browser { flex: 1 1 auto !important; min-width: 0; min-height: 0; }
1331
1377
  }
1332
1378
 
1333
1379
  /* --- Subagent UI --- */
package/src/web-ui.ts CHANGED
@@ -130,9 +130,12 @@ ${WEB_UI_STYLES}
130
130
 
131
131
  <div id="app" class="shell hidden">
132
132
  <aside class="sidebar">
133
- <button id="new-chat" class="new-chat-btn">
134
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
135
- </button>
133
+ <div class="sidebar-header">
134
+ <span class="sidebar-agent-name">${agentName}</span>
135
+ <button id="new-chat" class="new-chat-btn">
136
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
137
+ </button>
138
+ </div>
136
139
  <div id="conversation-list" class="conversation-list"></div>
137
140
  <div class="sidebar-footer">
138
141
  <button id="logout" class="logout-btn">Log out</button>