pi-ui-extend 0.1.28 → 0.1.31

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.
Files changed (34) hide show
  1. package/dist/app/app.d.ts +2 -0
  2. package/dist/app/app.js +31 -8
  3. package/dist/app/cli/update.d.ts +5 -0
  4. package/dist/app/cli/update.js +29 -1
  5. package/dist/app/model/model-usage-status.d.ts +2 -0
  6. package/dist/app/model/model-usage-status.js +90 -20
  7. package/dist/app/session/session-event-controller.d.ts +17 -1
  8. package/dist/app/session/session-event-controller.js +28 -0
  9. package/dist/app/session/tabs-controller.d.ts +10 -0
  10. package/dist/app/session/tabs-controller.js +65 -28
  11. package/external/pi-tools-suite/package.json +0 -3
  12. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  13. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +1 -1
  14. package/external/pi-tools-suite/src/async-subagents/index.ts +1 -1
  15. package/external/pi-tools-suite/src/async-subagents/render.ts +1 -1
  16. package/external/pi-tools-suite/src/async-subagents/tools/cleanup.ts +2 -2
  17. package/external/pi-tools-suite/src/async-subagents/tools/result.ts +2 -2
  18. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +3 -3
  19. package/external/pi-tools-suite/src/async-subagents/tools/status.ts +3 -3
  20. package/external/pi-tools-suite/src/async-subagents/tools/stop.ts +2 -2
  21. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +3 -3
  22. package/external/pi-tools-suite/src/async-subagents/tools/wait.ts +2 -2
  23. package/external/pi-tools-suite/src/async-subagents/ui.ts +1 -1
  24. package/external/pi-tools-suite/src/dcp/commands.ts +2 -2
  25. package/external/pi-tools-suite/src/dcp/compress-tool.ts +1 -1
  26. package/external/pi-tools-suite/src/dcp/index.ts +1 -1
  27. package/external/pi-tools-suite/src/lsp/constants.ts +1 -0
  28. package/external/pi-tools-suite/src/lsp/manager.ts +120 -71
  29. package/external/pi-tools-suite/src/model-tools/apply-patch.ts +1 -1
  30. package/external/pi-tools-suite/src/model-tools/index.ts +1 -1
  31. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +2 -2
  32. package/external/pi-tools-suite/src/tool-descriptions.ts +2 -2
  33. package/external/pi-tools-suite/src/usage/lib/google.ts +39 -4
  34. package/package.json +4 -7
@@ -12,6 +12,7 @@ const BACKGROUND_PREWARM_TAB_LIMIT = 2;
12
12
  const TAB_ATTENTION_BLINK_KEY = "tab-attention";
13
13
  const LOADING_TAB_TITLE_PATTERN = /^loading(?:…|\.\.\.)?$/iu;
14
14
  const DEFAULT_SESSION_TITLE_PATTERN = /^session [0-9a-f]{8}$/iu;
15
+ const SESSION_TITLE_HEAD_SCAN_MAX_BYTES = 256 * 1024;
15
16
  const SESSION_TITLE_SCAN_MAX_BYTES = 2 * 1024 * 1024;
16
17
  export class AppTabsController {
17
18
  host;
@@ -23,6 +24,7 @@ export class AppTabsController {
23
24
  historyReloadTimersByTabId = new Map();
24
25
  inputStatesByTabId = new Map();
25
26
  deferredUserMessagesByTabId = new Map();
27
+ sessionViewsByTabId = new Map();
26
28
  tabIdsNeedingHistoryReload = new Set();
27
29
  activeTabId;
28
30
  pendingActiveTabId;
@@ -576,6 +578,7 @@ export class AppTabsController {
576
578
  const previousRuntime = runtime;
577
579
  const previousTargetActivity = target.activity;
578
580
  this.storeActiveRuntime(runtime);
581
+ this.storeActiveSessionView();
579
582
  this.storeActiveInputState();
580
583
  this.storeActiveDeferredUserMessages();
581
584
  this.activeTabId = target.id;
@@ -584,8 +587,6 @@ export class AppTabsController {
584
587
  this.clearTabAttention(target);
585
588
  this.restoreInputState(target.id);
586
589
  this.host.closeMenusForTabSwitch?.();
587
- this.host.resetSessionView();
588
- this.restoreDeferredUserMessages(target.id);
589
590
  this.host.setStatus("switching tab");
590
591
  this.host.setSessionActivity("thinking");
591
592
  this.host.render();
@@ -633,7 +634,18 @@ export class AppTabsController {
633
634
  this.restoreInputState(target.id);
634
635
  void this.saveTabs();
635
636
  this.scheduleTabPrewarm();
636
- await this.loadActiveSessionHistory(targetRuntime);
637
+ const cachedView = this.sessionViewsByTabId.get(target.id);
638
+ if (cachedView && this.host.restoreSessionView) {
639
+ this.host.restoreSessionView(cachedView);
640
+ this.restoreDeferredUserMessages(target.id);
641
+ this.host.setSessionStatus(targetRuntime.session);
642
+ this.host.setSessionActivity(this.sessionActivity(targetRuntime.session));
643
+ this.host.render();
644
+ }
645
+ else {
646
+ await this.loadActiveSessionHistory(targetRuntime);
647
+ this.tabIdsNeedingHistoryReload.delete(target.id);
648
+ }
637
649
  this.scheduleDelayedHistoryReload(target.id, targetRuntime);
638
650
  }
639
651
  async closeTab(tabId) {
@@ -809,6 +821,11 @@ export class AppTabsController {
809
821
  return;
810
822
  this.setRuntimeForTab(this.activeTabId, runtime);
811
823
  }
824
+ storeActiveSessionView() {
825
+ if (!this.activeTabId || !this.host.captureSessionView)
826
+ return;
827
+ this.sessionViewsByTabId.set(this.activeTabId, this.host.captureSessionView());
828
+ }
812
829
  setRuntimeForTab(tabId, runtime) {
813
830
  this.runtimesByTabId.set(tabId, runtime);
814
831
  this.observeRuntimeForTab(tabId, runtime);
@@ -816,6 +833,7 @@ export class AppTabsController {
816
833
  deleteRuntimeForTab(tabId) {
817
834
  this.runtimesByTabId.delete(tabId);
818
835
  this.runtimeLoadsByTabId.delete(tabId);
836
+ this.sessionViewsByTabId.delete(tabId);
819
837
  this.clearRuntimeRefreshTimers(tabId);
820
838
  this.clearHistoryReloadTimers(tabId);
821
839
  this.tabIdsNeedingHistoryReload.delete(tabId);
@@ -900,11 +918,15 @@ export class AppTabsController {
900
918
  return;
901
919
  if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined)
902
920
  return;
921
+ if (this.sessionActivity(runtime.session) === "running") {
922
+ this.clearHistoryReloadTimers(tabId);
923
+ return;
924
+ }
903
925
  this.clearHistoryReloadTimers(tabId);
904
926
  for (const delayMs of [150, 1000, 3000]) {
905
927
  const timer = setTimeout(() => {
906
928
  this.historyReloadTimersByTabId.get(tabId)?.delete(timer);
907
- void this.reloadActiveTabHistoryIfNeeded(tabId, runtime, delayMs === 3000);
929
+ void this.reloadActiveTabHistoryIfNeeded(tabId, runtime);
908
930
  }, delayMs);
909
931
  timer.unref?.();
910
932
  let timers = this.historyReloadTimersByTabId.get(tabId);
@@ -915,13 +937,15 @@ export class AppTabsController {
915
937
  timers.add(timer);
916
938
  }
917
939
  }
918
- async reloadActiveTabHistoryIfNeeded(tabId, runtime, finalAttempt) {
940
+ async reloadActiveTabHistoryIfNeeded(tabId, runtime) {
919
941
  if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined || this.host.runtime() !== runtime)
920
942
  return;
921
943
  if (!this.tabIdsNeedingHistoryReload.has(tabId))
922
944
  return;
945
+ if (this.sessionActivity(runtime.session) === "running")
946
+ return;
923
947
  await this.loadActiveSessionHistory(runtime);
924
- if (finalAttempt && tabId === this.activeTabId && this.host.runtime() === runtime) {
948
+ if (tabId === this.activeTabId && this.host.runtime() === runtime) {
925
949
  this.tabIdsNeedingHistoryReload.delete(tabId);
926
950
  }
927
951
  }
@@ -1027,6 +1051,7 @@ export class AppTabsController {
1027
1051
  this.clearRuntimeSubscriptions();
1028
1052
  this.inputStatesByTabId.clear();
1029
1053
  this.deferredUserMessagesByTabId.clear();
1054
+ this.sessionViewsByTabId.clear();
1030
1055
  const seen = new Set();
1031
1056
  for (const tab of tabs) {
1032
1057
  const sessionPath = tab.sessionPath ? resolve(tab.sessionPath) : undefined;
@@ -1505,28 +1530,14 @@ async function readLatestSessionTitle(sessionPath) {
1505
1530
  const { size } = await file.stat();
1506
1531
  if (size <= 0)
1507
1532
  return undefined;
1508
- const byteCount = Math.min(size, SESSION_TITLE_SCAN_MAX_BYTES);
1509
- const buffer = Buffer.alloc(byteCount);
1510
- await file.read(buffer, 0, byteCount, size - byteCount);
1511
- const text = buffer.toString("utf8");
1512
- const lines = text.split("\n");
1513
- if (size > byteCount)
1514
- lines.shift();
1515
- for (let index = lines.length - 1; index >= 0; index -= 1) {
1516
- const line = lines[index]?.trim();
1517
- if (!line)
1518
- continue;
1519
- let parsed;
1520
- try {
1521
- parsed = JSON.parse(line);
1522
- }
1523
- catch {
1524
- continue;
1525
- }
1526
- if (!isRecord(parsed) || parsed.type !== "session_info" || typeof parsed.name !== "string")
1527
- continue;
1528
- return validSessionTitle(parsed.name);
1529
- }
1533
+ const tailByteCount = Math.min(size, SESSION_TITLE_SCAN_MAX_BYTES);
1534
+ const tailTitle = await readSessionTitleChunk(file, size - tailByteCount, tailByteCount, { dropFirstLine: size > tailByteCount });
1535
+ if (tailTitle)
1536
+ return tailTitle;
1537
+ if (size <= tailByteCount)
1538
+ return undefined;
1539
+ const headByteCount = Math.min(size - tailByteCount, SESSION_TITLE_HEAD_SCAN_MAX_BYTES);
1540
+ return await readSessionTitleChunk(file, 0, headByteCount, { dropLastLine: headByteCount < size });
1530
1541
  }
1531
1542
  catch {
1532
1543
  return undefined;
@@ -1534,6 +1545,32 @@ async function readLatestSessionTitle(sessionPath) {
1534
1545
  finally {
1535
1546
  await file?.close();
1536
1547
  }
1548
+ }
1549
+ async function readSessionTitleChunk(file, position, byteCount, options = {}) {
1550
+ if (byteCount <= 0)
1551
+ return undefined;
1552
+ const buffer = Buffer.alloc(byteCount);
1553
+ await file.read(buffer, 0, byteCount, position);
1554
+ const lines = buffer.toString("utf8").split("\n");
1555
+ if (options.dropFirstLine)
1556
+ lines.shift();
1557
+ if (options.dropLastLine)
1558
+ lines.pop();
1559
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1560
+ const line = lines[index]?.trim();
1561
+ if (!line)
1562
+ continue;
1563
+ let parsed;
1564
+ try {
1565
+ parsed = JSON.parse(line);
1566
+ }
1567
+ catch {
1568
+ continue;
1569
+ }
1570
+ if (!isRecord(parsed) || parsed.type !== "session_info" || typeof parsed.name !== "string")
1571
+ continue;
1572
+ return validSessionTitle(parsed.name);
1573
+ }
1537
1574
  return undefined;
1538
1575
  }
1539
1576
  function validSessionTitle(value) {
@@ -41,9 +41,6 @@
41
41
  "@earendil-works/pi-ai": "*",
42
42
  "@earendil-works/pi-coding-agent": "*",
43
43
  "@earendil-works/pi-tui": "*",
44
- "@mariozechner/pi-ai": "*",
45
- "@mariozechner/pi-coding-agent": "*",
46
- "@mariozechner/pi-tui": "*",
47
44
  "typebox": "*"
48
45
  },
49
46
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export const SUBAGENT_DENIED_TOOLS = new Set([
4
4
  "question",
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import {
@@ -1,4 +1,4 @@
1
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { AgentState } from "./lib.js";
3
3
  import { modelName, plural, statusGlyph, statusLabel } from "./format.js";
4
4
  import type { AgentTaskPreview, SubagentRunRenderDetails } from "./types.js";
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
3
  import * as path from "node:path";
4
4
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
5
5
  import { readResult, resolveSubagentAgentRunDir, validateBasename } from "../lib.js";
@@ -1,8 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
- import { Type } from "@mariozechner/pi-ai";
5
- import { Text } from "@mariozechner/pi-tui";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "@earendil-works/pi-ai";
5
+ import { Text } from "@earendil-works/pi-tui";
6
6
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
7
7
  import type { AgentCompletionHandler, AgentTask, ResolvedAgentTaskConfig, Semaphore, SpawnedAgent } from "../lib.js";
8
8
  import {
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
3
- import { Text } from "@mariozechner/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
5
5
  import { getRunState, resolveSubagentRunDir, validateBasename } from "../lib.js";
6
6
  import { INLINE_RENDERING } from "../constants.js";
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
3
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
4
4
  import { getRunState, resolveSubagentRunDir, stopAgents, validateBasename, validateStopSignal } from "../lib.js";
5
5
  import { INLINE_RENDERING } from "../constants.js";
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
3
- import { Text } from "@mariozechner/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { asyncSubagentToolDescriptions } from "../../tool-descriptions.js";
5
5
  import { hasIndexedProjectRoot } from "../../lib/project.js";
6
6
  import type { AgentCompletionHandler } from "../lib.js";
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "@mariozechner/pi-ai";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@earendil-works/pi-ai";
3
3
  import { ASYNC_SUBAGENT_TOOL_DESCRIPTIONS } from "../../tool-descriptions.js";
4
4
  import { resolveSubagentRunDir, validateBasename } from "../lib.js";
5
5
  import { INLINE_RENDERING } from "../constants.js";
@@ -1,4 +1,4 @@
1
- import { Container } from "@mariozechner/pi-tui";
1
+ import { Container } from "@earendil-works/pi-tui";
2
2
 
3
3
  export function emptyToolSlot(): Container {
4
4
  return new Container();
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
2
- import type { AutocompleteItem } from "@mariozechner/pi-tui"
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui"
3
3
  import type { DcpState } from "./state.js"
4
4
  import type { DcpConfig } from "./config.js"
5
5
  import type { DcpNudgeType } from "./pruner-types.js"
@@ -3,7 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import { Type } from "typebox"
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
7
7
  import type { DcpState } from "./state.js"
8
8
  import type { DcpConfig } from "./config.js"
9
9
  import { clearDcpNudgeAnchors } from "./pruner.js"
@@ -2,7 +2,7 @@
2
2
  // Dynamic Context Pruning (DCP) — module entry point for pi-tools-suite
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
6
6
  import { loadConfig } from "./config.js"
7
7
  import {
8
8
  createState,
@@ -1,6 +1,7 @@
1
1
  export const DEFAULT_STARTUP_TIMEOUT_MS = 45_000;
2
2
  export const DEFAULT_DIAGNOSTICS_WAIT_MS = 10_000;
3
3
  export const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
4
+ export const DEFAULT_IDLE_SHUTDOWN_MS = 30_000;
4
5
  export const REQUEST_TIMEOUT_MS = 30_000;
5
6
  export const SHUTDOWN_WRITE_TIMEOUT_MS = 100;
6
7
  export const SHUTDOWN_TERM_TIMEOUT_MS = 2_000;
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { DEFAULT_DIAGNOSTICS_WAIT_MS, DEFAULT_MAX_FILE_SIZE_BYTES, LSP_MANAGER_GLOBAL_KEY } from "./constants";
3
+ import { DEFAULT_DIAGNOSTICS_WAIT_MS, DEFAULT_IDLE_SHUTDOWN_MS, DEFAULT_MAX_FILE_SIZE_BYTES, LSP_MANAGER_GLOBAL_KEY } from "./constants";
4
4
  import { DiagnosticsStore } from "./diagnostics-store";
5
5
  import { LspClient } from "./client";
6
6
  import { loadLspConfig } from "./_shared/config";
@@ -43,12 +43,51 @@ export class LspManager {
43
43
  private readonly diagnostics = new DiagnosticsStore();
44
44
  private readonly clients = new Map<string, LspClient>();
45
45
  private readonly backoff = new Map<string, { retryAt: number; attempts: number; reason: string }>();
46
+ private idleShutdownTimer: ReturnType<typeof setTimeout> | undefined;
47
+ private activeOperations = 0;
48
+ private handlingSignal = false;
46
49
  private readonly handleProcessExit = () => {
47
50
  this.shutdownAllSync();
48
51
  };
52
+ private readonly handleProcessSignal = (signal: NodeJS.Signals) => {
53
+ this.shutdownAllSync();
54
+
55
+ // Restore the platform default for the terminating signal. LSP servers are
56
+ // spawned detached so they can otherwise outlive Pi when the process is
57
+ // killed by a terminal/editor without a session_shutdown event.
58
+ if (this.handlingSignal) return;
59
+ this.handlingSignal = true;
60
+ process.kill(process.pid, signal);
61
+ };
49
62
 
50
63
  constructor() {
51
64
  process.once("exit", this.handleProcessExit);
65
+ process.once("SIGINT", this.handleProcessSignal);
66
+ process.once("SIGTERM", this.handleProcessSignal);
67
+ process.once("SIGHUP", this.handleProcessSignal);
68
+ }
69
+
70
+ private clearIdleShutdownTimer(): void {
71
+ if (!this.idleShutdownTimer) return;
72
+ clearTimeout(this.idleShutdownTimer);
73
+ this.idleShutdownTimer = undefined;
74
+ }
75
+
76
+ private beginOperation(): void {
77
+ this.activeOperations += 1;
78
+ this.clearIdleShutdownTimer();
79
+ }
80
+
81
+ private endOperation(): void {
82
+ this.activeOperations = Math.max(0, this.activeOperations - 1);
83
+ if (this.activeOperations > 0 || this.clients.size === 0) return;
84
+
85
+ this.clearIdleShutdownTimer();
86
+ this.idleShutdownTimer = setTimeout(() => {
87
+ if (this.activeOperations > 0 || this.clients.size === 0) return;
88
+ void this.shutdownAll();
89
+ }, DEFAULT_IDLE_SHUTDOWN_MS);
90
+ this.idleShutdownTimer.unref?.();
52
91
  }
53
92
 
54
93
  async matchingServers(ctx: ExtensionContext, file: string): Promise<{ matches: MatchedServer[]; warnings: string[]; workspace: string }> {
@@ -104,93 +143,98 @@ export class LspManager {
104
143
  }
105
144
 
106
145
  async updateDiagnosticsForFile(ctx: ExtensionContext, file: string): Promise<string> {
107
- const { matches, warnings, workspace } = await this.matchingServers(ctx, file);
108
- if (matches.length === 0) return formatWarnings("LSP diagnostics", warnings);
109
-
110
- const lines: string[] = [];
111
- for (const match of matches) {
112
- try {
113
- const maxFileSizeBytes = match.server.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
114
- if (!(await fileSizeAllowed(file, maxFileSizeBytes))) {
115
- lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: skipped ${match.relFile}; file exceeds maxFileSizeBytes (${maxFileSizeBytes})`);
116
- continue;
117
- }
146
+ this.beginOperation();
147
+ try {
148
+ const { matches, warnings, workspace } = await this.matchingServers(ctx, file);
149
+ if (matches.length === 0) return formatWarnings("LSP diagnostics", warnings);
118
150
 
119
- // Clear stale diagnostics before refreshing this file. The synchronous
120
- // wait below must observe a fresh publishDiagnostics notification, not an
121
- // old error from a previous document version. Empty diagnostics published
122
- // by the server are stored, but this local clear is not.
123
- this.diagnostics.clear(match.server.id, match.root, filePathToUri(file));
124
-
125
- const text = await readTextFile(file);
126
- const client = await this.getClient(match.server, match.root, file, workspace, ctx.signal);
127
- const languageId = languageIdForFile(match.server, file);
128
- const startedAt = Date.now();
129
- const doc = await client.openOrChange(file, languageId, text, ctx.signal);
130
- await client.didSave(file);
131
- const diagnosticsWaitMs = match.server.diagnosticsWaitMs ?? DEFAULT_DIAGNOSTICS_WAIT_MS;
132
-
133
- // typescript-language-server sometimes does not emit a fresh
134
- // textDocument/publishDiagnostics notification after didChange/didSave,
135
- // even though tsserver can answer diagnostics synchronously. Prefer the
136
- // explicit tsserver request when the server exposes it, so post-edit
137
- // diagnostics don't degrade into a misleading publishDiagnostics timeout.
138
- let tsserverFallbackError: string | undefined;
151
+ const lines: string[] = [];
152
+ for (const match of matches) {
139
153
  try {
140
- const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs, ctx.signal);
141
- if (tsserverDiagnostics !== undefined) {
142
- const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, tsserverDiagnostics);
143
- this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
144
- lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
154
+ const maxFileSizeBytes = match.server.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
155
+ if (!(await fileSizeAllowed(file, maxFileSizeBytes))) {
156
+ lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: skipped ${match.relFile}; file exceeds maxFileSizeBytes (${maxFileSizeBytes})`);
145
157
  continue;
146
158
  }
147
- } catch (error) {
148
- tsserverFallbackError = (error as Error).message;
149
- }
150
159
 
151
- let pullDiagnosticsError: string | undefined;
152
- if (match.server.pullDiagnostics !== false) {
160
+ // Clear stale diagnostics before refreshing this file. The synchronous
161
+ // wait below must observe a fresh publishDiagnostics notification, not an
162
+ // old error from a previous document version. Empty diagnostics published
163
+ // by the server are stored, but this local clear is not.
164
+ this.diagnostics.clear(match.server.id, match.root, filePathToUri(file));
165
+
166
+ const text = await readTextFile(file);
167
+ const client = await this.getClient(match.server, match.root, file, workspace, ctx.signal);
168
+ const languageId = languageIdForFile(match.server, file);
169
+ const startedAt = Date.now();
170
+ const doc = await client.openOrChange(file, languageId, text, ctx.signal);
171
+ await client.didSave(file);
172
+ const diagnosticsWaitMs = match.server.diagnosticsWaitMs ?? DEFAULT_DIAGNOSTICS_WAIT_MS;
173
+
174
+ // typescript-language-server sometimes does not emit a fresh
175
+ // textDocument/publishDiagnostics notification after didChange/didSave,
176
+ // even though tsserver can answer diagnostics synchronously. Prefer the
177
+ // explicit tsserver request when the server exposes it, so post-edit
178
+ // diagnostics don't degrade into a misleading publishDiagnostics timeout.
179
+ let tsserverFallbackError: string | undefined;
153
180
  try {
154
- const pulledDiagnostics = await client.pullDiagnostics(file, diagnosticsWaitMs, ctx.signal);
155
- if (pulledDiagnostics !== undefined) {
156
- const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, pulledDiagnostics);
181
+ const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs, ctx.signal);
182
+ if (tsserverDiagnostics !== undefined) {
183
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, tsserverDiagnostics);
157
184
  this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
158
185
  lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
159
186
  continue;
160
187
  }
161
188
  } catch (error) {
162
- pullDiagnosticsError = (error as Error).message;
189
+ tsserverFallbackError = (error as Error).message;
163
190
  }
164
- }
165
191
 
166
- if (match.server.waitForPublishDiagnostics === false || diagnosticsWaitMs <= 0) {
167
- continue;
168
- }
192
+ let pullDiagnosticsError: string | undefined;
193
+ if (match.server.pullDiagnostics !== false) {
194
+ try {
195
+ const pulledDiagnostics = await client.pullDiagnostics(file, diagnosticsWaitMs, ctx.signal);
196
+ if (pulledDiagnostics !== undefined) {
197
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, pulledDiagnostics);
198
+ this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
199
+ lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
200
+ continue;
201
+ }
202
+ } catch (error) {
203
+ pullDiagnosticsError = (error as Error).message;
204
+ }
205
+ }
206
+
207
+ if (match.server.waitForPublishDiagnostics === false || diagnosticsWaitMs <= 0) {
208
+ continue;
209
+ }
169
210
 
170
- const entry = await this.diagnostics.waitForFile(
171
- match.server.id,
172
- match.root,
173
- file,
174
- startedAt,
175
- doc.version,
176
- diagnosticsWaitMs,
177
- ctx.signal,
178
- );
179
- if (!isFreshDiagnosticsEntry(entry, startedAt, doc.version)) {
180
- const fallbackSuffix = tsserverFallbackError ? `; tsserver fallback failed: ${tsserverFallbackError}` : "";
181
- const pullSuffix = pullDiagnosticsError ? `; pull diagnostics failed: ${pullDiagnosticsError}` : "";
182
- lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: timed out after ${diagnosticsWaitMs}ms waiting for fresh diagnostics for ${match.relFile}${fallbackSuffix}${pullSuffix}`);
183
- continue;
211
+ const entry = await this.diagnostics.waitForFile(
212
+ match.server.id,
213
+ match.root,
214
+ file,
215
+ startedAt,
216
+ doc.version,
217
+ diagnosticsWaitMs,
218
+ ctx.signal,
219
+ );
220
+ if (!isFreshDiagnosticsEntry(entry, startedAt, doc.version)) {
221
+ const fallbackSuffix = tsserverFallbackError ? `; tsserver fallback failed: ${tsserverFallbackError}` : "";
222
+ const pullSuffix = pullDiagnosticsError ? `; pull diagnostics failed: ${pullDiagnosticsError}` : "";
223
+ lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: timed out after ${diagnosticsWaitMs}ms waiting for fresh diagnostics for ${match.relFile}${fallbackSuffix}${pullSuffix}`);
224
+ continue;
225
+ }
226
+ const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, entry.diagnostics);
227
+ if (diagnostics !== entry.diagnostics) this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
228
+ lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
229
+ } catch (error) {
230
+ lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: ${(error as Error).message}`);
184
231
  }
185
- const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, entry.diagnostics);
186
- if (diagnostics !== entry.diagnostics) this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
187
- lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
188
- } catch (error) {
189
- lines.push(`${LSP_DIAGNOSTIC_ICON} ${match.server.id}: ${(error as Error).message}`);
190
232
  }
191
- }
192
233
 
193
- return [formatWarnings("LSP diagnostics", warnings), joinSections("LSP diagnostics", lines)].filter(Boolean).join("\n\n");
234
+ return [formatWarnings("LSP diagnostics", warnings), joinSections("LSP diagnostics", lines)].filter(Boolean).join("\n\n");
235
+ } finally {
236
+ this.endOperation();
237
+ }
194
238
  }
195
239
 
196
240
  async ensureDocumentForTool(ctx: ExtensionContext, inputPath: string): Promise<{ file: string; match: MatchedServer; client: LspClient; workspace: string } | undefined> {
@@ -215,15 +259,20 @@ export class LspManager {
215
259
  }
216
260
 
217
261
  async shutdownAll(): Promise<void> {
262
+ this.clearIdleShutdownTimer();
218
263
  const clients = [...this.clients.values()];
219
264
  this.clients.clear();
220
265
  await Promise.allSettled(clients.map((client) => client.shutdown()));
221
266
  }
222
267
 
223
268
  shutdownAllSync(): void {
269
+ this.clearIdleShutdownTimer();
224
270
  const clients = [...this.clients.values()];
225
271
  this.clients.clear();
226
272
  process.off("exit", this.handleProcessExit);
273
+ process.off("SIGINT", this.handleProcessSignal);
274
+ process.off("SIGTERM", this.handleProcessSignal);
275
+ process.off("SIGHUP", this.handleProcessSignal);
227
276
  for (const client of clients) client.shutdownSync();
228
277
  }
229
278
  }
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { withFileMutationQueue } from "@mariozechner/pi-coding-agent";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
6
  import { isPathInside } from "./path-utils";
7
7
 
8
8
  export interface ApplyPatchResult {
@@ -11,7 +11,7 @@ import {
11
11
  type ExtensionContext,
12
12
  type ToolDefinition,
13
13
  type ToolRenderResultOptions,
14
- } from "@mariozechner/pi-coding-agent";
14
+ } from "@earendil-works/pi-coding-agent";
15
15
  import { realpath } from "node:fs/promises";
16
16
  import { resolve } from "node:path";
17
17
  import { Type, type TSchema } from "typebox";
@@ -145,7 +145,7 @@ function appendWorkflowReminder(text: string, op: Op, state: TaskState): string
145
145
  const lines = [text];
146
146
  if (op.kind === "create" || op.kind === "batch_create") {
147
147
  lines.push(
148
- "Reminder: if this is a multi-step task, include a final todo item for the user-facing final report before completion. Give that final-report todo an explicit description/acceptance criteria: summarize changed files and behavior, list verification commands/results, mention any remaining manual action, and never replace the user-facing report with a compression/housekeeping note.",
148
+ "Reminder: if this is a multi-step task, include a final todo item for the user-facing final report before completion. Give that final-report todo an explicit description/acceptance criteria: summarize changed files and behavior, list verification commands/results, mention any remaining manual action, and never replace the user-facing report with a compression/housekeeping note. Close that report todo immediately before sending the report.",
149
149
  );
150
150
  const createdIds = new Set(op.kind === "create" ? [op.taskId] : op.ids);
151
151
  const hasOlderUnfinished = !op.replacedCount && state.tasks.some((task) => {
@@ -167,7 +167,7 @@ function appendWorkflowReminder(text: string, op: Op, state: TaskState): string
167
167
  }
168
168
  if (hasInProgress) {
169
169
  lines.push(
170
- "Reminder: before your final response, update any finished todo items to completed. Treat the final user-facing report step like any other todo: mark it completed immediately before sending the report.",
170
+ "Reminder: before your final response, update any finished todo items to completed. If one todo is the final user-facing report step, mark it completed immediately before sending the report.",
171
171
  );
172
172
  }
173
173
  return lines.join("\n\n");