pi-web-ui 0.27.1 → 0.28.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.
@@ -456,20 +456,26 @@ wss.on("connection", (ws) => {
456
456
  case "list_providers":
457
457
  void cs.listProviders();
458
458
  break;
459
- case "terminal_create":
460
- cs.terminals.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.cwd);
459
+ case "fetch_models":
460
+ void cs.fetchModelsList(msg.reqId, msg.baseUrl, msg.apiKey, msg.authHeader, msg.api);
461
461
  break;
462
+ case "terminal_create": {
463
+ const tm = cs.getTerminalManager(msg.conversationId);
464
+ if (tm)
465
+ tm.create(msg.terminalId, msg.cwd, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
466
+ break;
467
+ }
462
468
  case "terminal_input":
463
- cs.terminals.input(msg.terminalId, msg.data);
469
+ cs.getTerminalManager(msg.conversationId)?.input(msg.terminalId, msg.data);
464
470
  break;
465
471
  case "terminal_resize":
466
- cs.terminals.resize(msg.terminalId, msg.cols, msg.rows);
472
+ cs.getTerminalManager(msg.conversationId)?.resize(msg.terminalId, msg.cols, msg.rows);
467
473
  break;
468
474
  case "terminal_kill":
469
- cs.terminals.kill(msg.terminalId);
475
+ cs.getTerminalManager(msg.conversationId)?.kill(msg.terminalId);
470
476
  break;
471
477
  case "run_command":
472
- cs.terminals.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.cwd);
478
+ cs.getTerminalManager(msg.conversationId)?.runCommand(msg.terminalId, msg.command, msg.cols, msg.rows, cs.getTerminalCwd(msg.conversationId));
473
479
  break;
474
480
  case "list_commands":
475
481
  void cs.listCommands();
@@ -514,6 +520,8 @@ wss.on("connection", (ws) => {
514
520
  visionBridgeModel: msg.visionBridgeModel,
515
521
  visionBridgePromptMode: msg.visionBridgePromptMode,
516
522
  visionBridgePrompt: msg.visionBridgePrompt,
523
+ reviewPrompt: msg.reviewPrompt,
524
+ reviewDisabledSkills: msg.reviewDisabledSkills,
517
525
  });
518
526
  break;
519
527
  case "save_preset":
@@ -1,23 +1,22 @@
1
1
  /**
2
- * TerminalManager — per-client PTY sessions (node-pty) bridged over the
3
- * WebSocket protocol, plus the user command list persisted in
2
+ * TerminalManager — conversation-owned PTY sessions (node-pty) bridged over
3
+ * the WebSocket protocol, plus the user command list persisted in
4
4
  * `<workspaceRoot>/.pi/commands.json`.
5
5
  *
6
- * Each browser client gets its own manager; terminals are shared across that
7
- * client's tabs (they broadcast through the session's emit). When the last
8
- * socket for a client detaches, all its PTYs are killed so no orphaned
9
- * processes survive a closed tab / dropped connection.
6
+ * Each conversation gets its own manager; terminals are shared across browser
7
+ * tabs through the session emit. A socket drop does not kill them: the
8
+ * conversation owns their lifecycle and releases them on disposal.
10
9
  *
11
10
  * Commands file format:
12
11
  * { "commands": [ { "name": "dev", "command": "npm run dev", "cwd": "${pwd}" } ] }
13
12
  * `${pwd}` inside cwd/command resolves to the agent session's current working
14
13
  * directory (the same directory the agent operates in — see set_cwd).
15
14
  */
16
- import { chmodSync, existsSync, readdirSync, statSync } from "node:fs";
15
+ import { chmodSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
17
16
  import { mkdir, readFile, writeFile } from "node:fs/promises";
18
17
  import { createRequire } from "node:module";
19
18
  import { homedir } from "node:os";
20
- import { dirname, isAbsolute, join, resolve } from "node:path";
19
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
20
  // MUST run before node-pty is required: rewrites the installed node-pty copies
22
21
  // so their worker/agent handlers tolerate Node `--watch`'s IPC traffic (see the
23
22
  // module itself for details).
@@ -107,6 +106,11 @@ export async function saveCommandsFile(workspaceRoot, commands) {
107
106
  }
108
107
  }
109
108
  const isWindows = process.platform === "win32";
109
+ const MAX_TERMINALS = 16;
110
+ const MAX_TERMINAL_HISTORY = 32;
111
+ const MAX_OUTPUT = 200_000;
112
+ const MAX_INPUT = 64 * 1024;
113
+ const MAX_ID = 80;
110
114
  /** `-i` makes bash interactive; cmd.exe / powershell.exe are interactive on their own. */
111
115
  function bashArgs(shell) {
112
116
  return /[\\/]bash(\.exe)?$/i.test(shell) ? ["-i"] : [];
@@ -275,26 +279,112 @@ function launchdSpawnedOnMac() {
275
279
  return process.platform === "darwin" && process.ppid === 1;
276
280
  }
277
281
  /**
278
- * Owns one or more PTYs for a client. All output is forwarded as
282
+ * Translate a logical key (named key or single character) plus modifiers into
283
+ * the exact byte sequence a PTY expects. Named keys are routed by NAME, so a
284
+ * Ctrl/Alt combo is NEVER derived from the key's first letter: Ctrl+ArrowUp
285
+ * must produce `ESC[1;5A`, not Ctrl+A, and Ctrl+Enter `ESC[13;5u`, not Ctrl+E.
286
+ * - arrows / F1–F4 / Home / End keep their plain form when unmodified and
287
+ * gain the xterm modifier parameter (`ESC[1;<m>X`) under Shift/Alt/Ctrl;
288
+ * - other named keys (Enter/Tab/Backspace/Escape/Insert/Delete/PageUp/PageDown)
289
+ * fall back to the CSI-u form (`ESC[<code>;<m>u`) once modified;
290
+ * - plain characters: Ctrl maps A–Z to 0x01–0x1A (error for non-letters),
291
+ * Shift uppercases, Alt prefixes with ESC.
292
+ */
293
+ export function encodeTerminalKey(key, modifiers = {}) {
294
+ const named = {
295
+ Enter: "\r", Return: "\r", Tab: "\t", Backspace: "\x7f", Escape: "\x1b",
296
+ Up: "\x1b[A", ArrowUp: "\x1b[A", Down: "\x1b[B", ArrowDown: "\x1b[B",
297
+ Left: "\x1b[D", ArrowLeft: "\x1b[D", Right: "\x1b[C", ArrowRight: "\x1b[C",
298
+ Home: "\x1b[H", End: "\x1b[F", Delete: "\x1b[3~", Insert: "\x1b[2~",
299
+ PageUp: "\x1b[5~", PageDown: "\x1b[6~", F1: "\x1bOP", F2: "\x1bOQ", F3: "\x1bOR", F4: "\x1bOS",
300
+ };
301
+ let data = named[key] ?? (key.length === 1 ? key : "");
302
+ if (!data)
303
+ return { error: `不支持的终端按键:${key}` };
304
+ // xterm modifier encoding: 1=plain, 2=Shift, 3=Alt, 5=Ctrl,
305
+ // 6=Ctrl+Shift, 7=Ctrl+Alt, 8=Ctrl+Alt+Shift.
306
+ const modifier = 1 + (modifiers.shift ? 1 : 0) + (modifiers.alt ? 2 : 0) + (modifiers.ctrl ? 4 : 0);
307
+ const arrow = /^\x1b\[([A-DHF])$/.exec(data);
308
+ const functionKey = /^\x1bO([P-S])$/.exec(data);
309
+ const namedCode = {
310
+ Enter: 13, Return: 13, Tab: 9, Backspace: 127, Escape: 27,
311
+ Insert: 2, Delete: 3, Home: 1, End: 4, PageUp: 5, PageDown: 6,
312
+ };
313
+ if (arrow && modifier !== 1) {
314
+ data = `\x1b[1;${modifier}${arrow[1]}`;
315
+ }
316
+ else if (functionKey && modifier !== 1) {
317
+ data = `\x1b[1;${modifier}${functionKey[1]}`;
318
+ }
319
+ else if (namedCode[key] !== undefined && modifier !== 1) {
320
+ // CSI-u keeps named keys identifiable. In particular, Ctrl+Enter and
321
+ // Ctrl+Tab must not be derived from the first letter of "Enter"/"Tab".
322
+ data = `\x1b[${namedCode[key]};${modifier}u`;
323
+ }
324
+ else {
325
+ if (modifiers.ctrl) {
326
+ if (key.length !== 1)
327
+ return { error: `Ctrl 组合键无效:${key}` };
328
+ const code = key.toUpperCase().charCodeAt(0);
329
+ if (code >= 64 && code <= 95)
330
+ data = String.fromCharCode(code - 64);
331
+ else
332
+ return { error: `Ctrl 组合键无效:${key}` };
333
+ }
334
+ else if (modifiers.shift && key.length === 1) {
335
+ data = key.toUpperCase();
336
+ }
337
+ if (modifiers.alt)
338
+ data = "\x1b" + data;
339
+ }
340
+ return { data };
341
+ }
342
+ /**
343
+ * Owns one or more PTYs for a conversation. All output is forwarded as
279
344
  * `terminal_output` messages through the provided emit (broadcast to every
280
- * socket of the client). Returns false from create/runCommand when the spawn
281
- * failed (an error notice + terminal_exit are emitted instead).
345
+ * socket attached to the client session). Failed spawns emit an error notice and
346
+ * terminal_exit instead of throwing into the WebSocket dispatcher.
282
347
  */
283
348
  export class TerminalManager {
284
349
  emit;
350
+ workspaceRoot;
351
+ /** Live PTYs only. Exited entries move to history so they no longer consume
352
+ * the live-terminal limit while their output remains readable/replayable. */
285
353
  terms = new Map();
354
+ history = new Map();
286
355
  seq = 0;
287
356
  tccHintShown = false;
288
- constructor(emit) {
357
+ constructor(emit, workspaceRoot) {
289
358
  this.emit = emit;
359
+ this.workspaceRoot = workspaceRoot;
290
360
  }
291
361
  /** Start a plain interactive shell in the given directory. */
292
- create(id, cwd, cols, rows, fallbackCwd) {
362
+ create(id, cwd, cols, rows, fallbackCwd, title) {
363
+ const valid = this.validateId(id);
364
+ if (valid) {
365
+ this.fail(id, valid);
366
+ return null;
367
+ }
293
368
  if (this.terms.has(id))
294
- return;
295
- if (this.spawnShell(id, cwd || fallbackCwd, cols, rows, `终端 ${++this.seq}`)) {
369
+ return this.info(this.terms.get(id));
370
+ // Every spawn path shares the same admission rule (ensureSpawnAllowed):
371
+ // a NEW live PTY needs a free slot under the cap. Reusing an exited name
372
+ // starts a fresh PTY and discards its old history — but only after the
373
+ // slot check, so a rejected request keeps its retained output.
374
+ if (!this.ensureSpawnAllowed(id))
375
+ return null;
376
+ this.history.delete(id);
377
+ const safeCwd = this.safeCwd(cwd || fallbackCwd);
378
+ if (!safeCwd) {
379
+ this.fail(id, "终端工作目录必须位于当前工作区内");
380
+ return null;
381
+ }
382
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`)) {
296
383
  this.maybeEmitTccHint(id);
384
+ this.emitList();
385
+ return this.info(this.terms.get(id));
297
386
  }
387
+ return null;
298
388
  }
299
389
  /** Warn about unavailable camera/mic TCC grants in a fresh terminal, once per client. */
300
390
  maybeEmitTccHint(id) {
@@ -311,10 +401,27 @@ export class TerminalManager {
311
401
  * same terminal (used when re-running a command by clicking its entry).
312
402
  */
313
403
  runCommand(id, def, cols, rows, pwd) {
314
- const dir = resolveCommandCwd(def.cwd, pwd);
404
+ const invalidId = this.validateId(id);
405
+ if (invalidId) {
406
+ this.fail(id, invalidId);
407
+ return;
408
+ }
409
+ const existing = this.terms.get(id);
410
+ // Same admission rule as create(): a live terminal may be restarted in
411
+ // place, but a NEW live PTY needs a free slot — an id sitting in history
412
+ // (exited) does NOT grant one, or re-running exited terminals while at
413
+ // the cap could push the live count past MAX_TERMINALS.
414
+ if (!existing && !this.ensureSpawnAllowed(id))
415
+ return;
416
+ const hasHistory = this.history.has(id);
417
+ const rawDir = resolveCommandCwd(def.cwd, pwd);
418
+ const dir = this.safeCwd(rawDir);
315
419
  const command = expandPwd(def.command.trim(), pwd);
316
420
  const title = def.name || command || `终端 ${++this.seq}`;
317
- const existing = this.terms.get(id);
421
+ if (!dir) {
422
+ this.fail(id, "终端工作目录必须位于当前工作区内");
423
+ return;
424
+ }
318
425
  if (existing) {
319
426
  // Re-run in place: interrupt the current process (kill the PTY's
320
427
  // process group) and start a fresh shell with the same id. Keep the
@@ -332,26 +439,38 @@ export class TerminalManager {
332
439
  rows = existing.rows || rows;
333
440
  this.terms.delete(id);
334
441
  }
335
- const ok = this.spawnShell(id, dir, cols, rows, title);
442
+ this.history.delete(id);
443
+ const ok = this.spawnShell(id, dir, cols, rows, title, def);
336
444
  if (!ok)
337
445
  return;
446
+ this.emitList();
338
447
  // Clear the previous run's output, then show a banner and run the command
339
448
  // (the PTY input buffer holds it until the shell is ready).
340
- this.writeOut(id, "\x1b[2J\x1b[3J\x1b[H");
341
- this.writeOut(id, `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`);
449
+ const banner = "\x1b[2J\x1b[3J\x1b[H" +
450
+ `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
451
+ const fresh = this.terms.get(id);
452
+ if (fresh)
453
+ this.appendOutput(fresh, banner);
454
+ this.writeOut(id, banner);
342
455
  this.maybeEmitTccHint(id);
343
456
  if (command)
344
457
  this.input(id, command + "\r");
345
458
  }
346
459
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
347
- spawnShell(id, cwd, cols, rows, title) {
460
+ spawnShell(id, cwd, cols, rows, title, command) {
348
461
  let abs = cwd;
349
462
  if (!abs)
350
463
  abs = homedir();
351
464
  else if (!isAbsolute(abs))
352
465
  abs = resolve(abs);
353
- if (!existsSync(abs)) {
354
- this.fail(id, `目录不存在:${abs}`);
466
+ try {
467
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
468
+ this.fail(id, `目录不存在或不是目录:${abs}`);
469
+ return false;
470
+ }
471
+ }
472
+ catch {
473
+ this.fail(id, `无法访问终端目录:${abs}`);
355
474
  return false;
356
475
  }
357
476
  // node-pty's spawn-helper may have lost its +x bit since the last repair
@@ -383,6 +502,11 @@ export class TerminalManager {
383
502
  cols: Math.max(2, Math.floor(cols) || 80),
384
503
  rows: Math.max(2, Math.floor(rows) || 24),
385
504
  exited: false,
505
+ exitCode: null,
506
+ command,
507
+ output: "",
508
+ outputOffset: 0,
509
+ waiters: new Set(),
386
510
  };
387
511
  this.terms.set(id, entry);
388
512
  // The closures capture `entry`: after a restart the map points at the
@@ -390,6 +514,7 @@ export class TerminalManager {
390
514
  pty.onData((data) => {
391
515
  if (this.terms.get(id) !== entry)
392
516
  return;
517
+ this.appendOutput(entry, data);
393
518
  this.writeOut(id, data);
394
519
  });
395
520
  pty.onExit(({ exitCode }) => {
@@ -401,10 +526,132 @@ export class TerminalManager {
401
526
  }
402
527
  writeOut(id, data) {
403
528
  const entry = this.terms.get(id);
404
- if (!entry || entry.exited)
529
+ if (!entry)
405
530
  return;
406
531
  this.emit({ type: "terminal_output", terminalId: id, data });
407
532
  }
533
+ appendOutput(entry, data) {
534
+ entry.output += data;
535
+ if (entry.output.length > MAX_OUTPUT) {
536
+ const drop = entry.output.length - MAX_OUTPUT;
537
+ entry.output = entry.output.slice(drop);
538
+ entry.outputOffset += drop;
539
+ }
540
+ for (const wake of entry.waiters)
541
+ wake();
542
+ entry.waiters.clear();
543
+ }
544
+ validateId(id) {
545
+ if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
546
+ return "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)";
547
+ }
548
+ return null;
549
+ }
550
+ /**
551
+ * Admission control for EVERY spawn path (create / runCommand): spawning a
552
+ * NEW live PTY is only allowed while the live count is below MAX_TERMINALS.
553
+ * Restarting an id that is ALREADY live is always allowed (no extra slot).
554
+ * History entries (exited terminals) do not reserve a slot — re-spawning
555
+ * one while at the cap is rejected with the standard error feedback.
556
+ */
557
+ ensureSpawnAllowed(id) {
558
+ if (this.terms.has(id))
559
+ return true;
560
+ if (this.terms.size >= MAX_TERMINALS) {
561
+ this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
562
+ return false;
563
+ }
564
+ return true;
565
+ }
566
+ safeCwd(raw) {
567
+ try {
568
+ const root = realpathSync(resolve(this.workspaceRoot));
569
+ const candidate = realpathSync(isAbsolute(raw) ? resolve(raw) : resolve(root, raw));
570
+ const rel = relative(root, candidate);
571
+ if (rel === "" || (!rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel))) {
572
+ return candidate;
573
+ }
574
+ }
575
+ catch {
576
+ // Missing directories and broken symlinks are rejected by the boundary.
577
+ }
578
+ return null;
579
+ }
580
+ info(entry) {
581
+ return {
582
+ id: entry.id,
583
+ title: entry.title,
584
+ cwd: entry.cwd,
585
+ cols: entry.cols,
586
+ rows: entry.rows,
587
+ running: !entry.exited,
588
+ exitCode: entry.exitCode,
589
+ command: entry.command,
590
+ };
591
+ }
592
+ has(id) {
593
+ return this.terms.has(id) || this.history.has(id);
594
+ }
595
+ find(id) {
596
+ return this.terms.get(id) ?? this.history.get(id);
597
+ }
598
+ list() {
599
+ return [...this.terms.values(), ...this.history.values()].map((entry) => this.info(entry));
600
+ }
601
+ emitList() {
602
+ this.emit({ type: "terminal_list", terminals: this.list() });
603
+ }
604
+ /** Replay the retained output window after switching back to this conversation. */
605
+ replay() {
606
+ return [...this.terms.values(), ...this.history.values()]
607
+ .filter((entry) => entry.output.length > 0)
608
+ .map((entry) => ({ terminalId: entry.id, data: entry.output }));
609
+ }
610
+ /** Read output after an absolute cursor. */
611
+ read(id, cursor = 0, maxBytes = 20_000) {
612
+ const entry = this.find(id);
613
+ if (!entry)
614
+ return null;
615
+ const start = Math.max(entry.outputOffset, Math.min(cursor, entry.outputOffset + entry.output.length));
616
+ const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
617
+ return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
618
+ }
619
+ async waitForOutput(id, cursor, timeoutMs, signal) {
620
+ const current = this.read(id, cursor, 1);
621
+ if (!current || current.cursor > cursor || !current.running)
622
+ return;
623
+ await new Promise((resolvePromise) => {
624
+ const entry = this.find(id);
625
+ if (!entry)
626
+ return resolvePromise();
627
+ let timer;
628
+ const done = () => {
629
+ if (timer)
630
+ clearTimeout(timer);
631
+ entry.waiters.delete(done);
632
+ signal?.removeEventListener("abort", done);
633
+ resolvePromise();
634
+ };
635
+ entry.waiters.add(done);
636
+ timer = setTimeout(done, Math.max(0, Math.min(timeoutMs, 120_000)));
637
+ signal?.addEventListener("abort", done, { once: true });
638
+ });
639
+ }
640
+ inputChecked(id, data) {
641
+ if (data.length > MAX_INPUT)
642
+ return `输入过长(上限 ${MAX_INPUT} 字符)`;
643
+ const entry = this.terms.get(id);
644
+ if (!entry || entry.exited)
645
+ return "终端不存在或进程已退出";
646
+ entry.pty.write(data);
647
+ return null;
648
+ }
649
+ key(id, key, modifiers = {}) {
650
+ const encoded = encodeTerminalKey(key, modifiers);
651
+ if ("error" in encoded)
652
+ return encoded.error;
653
+ return this.inputChecked(id, encoded.data);
654
+ }
408
655
  /** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
409
656
  fail(id, text) {
410
657
  this.emit({ type: "notice", level: "error", text });
@@ -419,14 +666,24 @@ export class TerminalManager {
419
666
  const entry = this.terms.get(id);
420
667
  if (!entry || entry.exited)
421
668
  return;
669
+ const banner = `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
670
+ this.appendOutput(entry, banner);
671
+ this.writeOut(id, banner);
422
672
  entry.exited = true;
423
- this.writeOut(id, `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`);
673
+ entry.exitCode = exitCode;
674
+ this.terms.delete(id);
675
+ while (this.history.size >= MAX_TERMINAL_HISTORY) {
676
+ const oldest = this.history.keys().next().value;
677
+ if (typeof oldest !== "string")
678
+ break;
679
+ this.history.delete(oldest);
680
+ }
681
+ this.history.set(id, entry);
424
682
  this.emit({ type: "terminal_exit", terminalId: id, exitCode });
683
+ this.emitList();
425
684
  }
426
685
  input(id, data) {
427
- const entry = this.terms.get(id);
428
- if (entry && !entry.exited)
429
- entry.pty.write(data);
686
+ void this.inputChecked(id, data);
430
687
  }
431
688
  resize(id, cols, rows) {
432
689
  const entry = this.terms.get(id);
@@ -442,22 +699,26 @@ export class TerminalManager {
442
699
  // PTY already gone — nothing to do.
443
700
  }
444
701
  }
445
- /** Kill one terminal (tab closed). The exit event is emitted by node-pty. */
702
+ /** Kill one terminal (tab closed), including an exited terminal's retained history. */
446
703
  kill(id) {
447
704
  const entry = this.terms.get(id);
448
- if (!entry || entry.exited)
705
+ if (entry) {
706
+ entry.exited = true;
707
+ try {
708
+ entry.pty.kill();
709
+ }
710
+ catch {
711
+ // already dead
712
+ }
713
+ this.terms.delete(id);
714
+ this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
715
+ this.emitList();
449
716
  return;
450
- entry.exited = true;
451
- try {
452
- entry.pty.kill();
453
- }
454
- catch {
455
- // already dead
456
717
  }
457
- this.terms.delete(id);
458
- this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
718
+ if (this.history.delete(id))
719
+ this.emitList();
459
720
  }
460
- /** Kill every terminal of this client (disconnect / dispose). */
721
+ /** Kill every terminal owned by this conversation. */
461
722
  killAll() {
462
723
  for (const entry of this.terms.values()) {
463
724
  if (entry.exited)
@@ -470,6 +731,13 @@ export class TerminalManager {
470
731
  // already dead
471
732
  }
472
733
  }
734
+ for (const entry of this.terms.values()) {
735
+ for (const wake of entry.waiters)
736
+ wake();
737
+ entry.waiters.clear();
738
+ }
473
739
  this.terms.clear();
740
+ this.history.clear();
741
+ this.emitList();
474
742
  }
475
743
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",