@poncho-ai/cli 0.17.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 {
@@ -1062,8 +1125,32 @@ export const getWebUiClientScript = (markedSource: string): string => `
1062
1125
  updateContextRing();
1063
1126
  }
1064
1127
  }
1128
+ if (eventName === "tool:generating") {
1129
+ const toolName = payload.tool || "tool";
1130
+ if (!Array.isArray(assistantMessage._activeActivities)) {
1131
+ assistantMessage._activeActivities = [];
1132
+ }
1133
+ assistantMessage._activeActivities.push({
1134
+ kind: "generating",
1135
+ tool: toolName,
1136
+ label: "Preparing " + toolName,
1137
+ });
1138
+ if (assistantMessage._currentText.length > 0) {
1139
+ assistantMessage._sections.push({
1140
+ type: "text",
1141
+ content: assistantMessage._currentText,
1142
+ });
1143
+ assistantMessage._currentText = "";
1144
+ }
1145
+ const prepText =
1146
+ "- preparing \\x60" + toolName + "\\x60";
1147
+ assistantMessage._currentTools.push(prepText);
1148
+ assistantMessage.metadata.toolActivity.push(prepText);
1149
+ renderIfActiveConversation(true);
1150
+ }
1065
1151
  if (eventName === "tool:started") {
1066
1152
  const toolName = payload.tool || "tool";
1153
+ removeActiveActivityForTool(assistantMessage, toolName);
1067
1154
  const startedActivity = addActiveActivityFromToolStart(
1068
1155
  assistantMessage,
1069
1156
  payload,
@@ -1075,14 +1162,24 @@ export const getWebUiClientScript = (markedSource: string): string => `
1075
1162
  });
1076
1163
  assistantMessage._currentText = "";
1077
1164
  }
1165
+ const tick = "\\x60";
1166
+ const prepPrefix = "- preparing " + tick + toolName + tick;
1167
+ const prepToolIdx = assistantMessage._currentTools.indexOf(prepPrefix);
1168
+ if (prepToolIdx >= 0) {
1169
+ assistantMessage._currentTools.splice(prepToolIdx, 1);
1170
+ }
1171
+ const prepMetaIdx = assistantMessage.metadata.toolActivity.indexOf(prepPrefix);
1172
+ if (prepMetaIdx >= 0) {
1173
+ assistantMessage.metadata.toolActivity.splice(prepMetaIdx, 1);
1174
+ }
1078
1175
  const detail =
1079
1176
  startedActivity && typeof startedActivity.detail === "string"
1080
1177
  ? startedActivity.detail.trim()
1081
1178
  : "";
1082
1179
  const toolText =
1083
- "- start \\x60" +
1180
+ "- start " + tick +
1084
1181
  toolName +
1085
- "\\x60" +
1182
+ tick +
1086
1183
  (detail ? " (" + detail + ")" : "");
1087
1184
  assistantMessage._currentTools.push(toolText);
1088
1185
  assistantMessage.metadata.toolActivity.push(toolText);
@@ -1805,26 +1902,57 @@ export const getWebUiClientScript = (markedSource: string): string => `
1805
1902
  updateContextRing();
1806
1903
  }
1807
1904
  }
1905
+ if (eventName === "tool:generating") {
1906
+ const toolName = payload.tool || "tool";
1907
+ if (!Array.isArray(assistantMessage._activeActivities)) {
1908
+ assistantMessage._activeActivities = [];
1909
+ }
1910
+ assistantMessage._activeActivities.push({
1911
+ kind: "generating",
1912
+ tool: toolName,
1913
+ label: "Preparing " + toolName,
1914
+ });
1915
+ if (assistantMessage._currentText.length > 0) {
1916
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
1917
+ assistantMessage._currentText = "";
1918
+ }
1919
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1920
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1921
+ const prepText = "- preparing \\x60" + toolName + "\\x60";
1922
+ assistantMessage._currentTools.push(prepText);
1923
+ assistantMessage.metadata.toolActivity.push(prepText);
1924
+ renderIfActiveConversation(true);
1925
+ }
1808
1926
  if (eventName === "tool:started") {
1809
1927
  const toolName = payload.tool || "tool";
1928
+ removeActiveActivityForTool(assistantMessage, toolName);
1810
1929
  const startedActivity = addActiveActivityFromToolStart(
1811
1930
  assistantMessage,
1812
1931
  payload,
1813
1932
  );
1814
- // If we have text accumulated, push it as a text section
1815
1933
  if (assistantMessage._currentText.length > 0) {
1816
1934
  assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
1817
1935
  assistantMessage._currentText = "";
1818
1936
  }
1937
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1938
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1939
+ const tick = "\\x60";
1940
+ const prepPrefix = "- preparing " + tick + toolName + tick;
1941
+ const prepToolIdx = assistantMessage._currentTools.indexOf(prepPrefix);
1942
+ if (prepToolIdx >= 0) {
1943
+ assistantMessage._currentTools.splice(prepToolIdx, 1);
1944
+ }
1945
+ const prepMetaIdx = assistantMessage.metadata.toolActivity.indexOf(prepPrefix);
1946
+ if (prepMetaIdx >= 0) {
1947
+ assistantMessage.metadata.toolActivity.splice(prepMetaIdx, 1);
1948
+ }
1819
1949
  const detail =
1820
1950
  startedActivity && typeof startedActivity.detail === "string"
1821
1951
  ? startedActivity.detail.trim()
1822
1952
  : "";
1823
1953
  const toolText =
1824
- "- start \\x60" + toolName + "\\x60" + (detail ? " (" + detail + ")" : "");
1954
+ "- start " + tick + toolName + tick + (detail ? " (" + detail + ")" : "");
1825
1955
  assistantMessage._currentTools.push(toolText);
1826
- if (!assistantMessage.metadata) assistantMessage.metadata = {};
1827
- if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1828
1956
  assistantMessage.metadata.toolActivity.push(toolText);
1829
1957
  renderIfActiveConversation(true);
1830
1958
  }
@@ -2278,45 +2406,20 @@ export const getWebUiClientScript = (markedSource: string): string => `
2278
2406
  openLightbox(img.src);
2279
2407
  });
2280
2408
 
2281
- elements.messages.addEventListener("click", async (event) => {
2282
- const target = event.target;
2283
- if (!(target instanceof Element)) {
2284
- return;
2285
- }
2286
- const button = target.closest(".approval-action-btn");
2287
- if (!button) {
2288
- return;
2289
- }
2290
- const approvalId = button.getAttribute("data-approval-id") || "";
2291
- const decision = button.getAttribute("data-approval-decision") || "";
2292
- if (!approvalId || (decision !== "approve" && decision !== "deny")) {
2293
- return;
2294
- }
2295
- if (state.approvalRequestsInFlight[approvalId]) {
2296
- return;
2297
- }
2409
+ const submitApproval = async (approvalId, decision, opts) => {
2410
+ const wasStreaming = opts && opts.wasStreaming;
2298
2411
  state.approvalRequestsInFlight[approvalId] = true;
2299
- const wasStreaming = state.isStreaming;
2300
- if (!wasStreaming) {
2301
- setStreaming(true);
2302
- }
2303
2412
  updatePendingApproval(approvalId, (request) => ({
2304
2413
  ...request,
2305
2414
  state: "submitting",
2306
2415
  pendingDecision: decision,
2307
2416
  }));
2308
- renderMessages(state.activeMessages, state.isStreaming);
2309
2417
  try {
2310
2418
  await api("/api/approvals/" + encodeURIComponent(approvalId), {
2311
2419
  method: "POST",
2312
2420
  body: JSON.stringify({ approved: decision === "approve" }),
2313
2421
  });
2314
2422
  updatePendingApproval(approvalId, () => null);
2315
- renderMessages(state.activeMessages, state.isStreaming);
2316
- loadConversations();
2317
- if (!wasStreaming && state.activeConversationId) {
2318
- await streamConversationEvents(state.activeConversationId, { liveOnly: true });
2319
- }
2320
2423
  } catch (error) {
2321
2424
  const isStale = error && error.payload && error.payload.code === "APPROVAL_NOT_FOUND";
2322
2425
  if (isStale) {
@@ -2330,13 +2433,77 @@ export const getWebUiClientScript = (markedSource: string): string => `
2330
2433
  _error: errMsg,
2331
2434
  }));
2332
2435
  }
2333
- renderMessages(state.activeMessages, state.isStreaming);
2334
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
+ }
2335
2473
  if (!wasStreaming) {
2336
2474
  setStreaming(false);
2337
2475
  renderMessages(state.activeMessages, false);
2338
2476
  }
2339
- 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);
2340
2507
  }
2341
2508
  });
2342
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>