@liushihao456/pi-emacs 0.1.2 → 0.1.4

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 (2) hide show
  1. package/index.ts +197 -18
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -2,9 +2,8 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { rgPath } from "@vscode/ripgrep";
6
5
  import { readdirSync, statSync } from "node:fs";
7
- import { spawn } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
8
7
  import { homedir } from "node:os";
9
8
  import path, { isAbsolute, resolve } from "node:path";
10
9
  import {
@@ -25,7 +24,11 @@ type Theme = {
25
24
 
26
25
  type EmacsState = {
27
26
  startedEmacsServer: boolean;
27
+ serverName: string;
28
+ legacyDefaultServer?: boolean;
28
29
  serverStartPromise?: Promise<void>;
30
+ watchdogPid?: number;
31
+ processShutdownHandlerVersion?: number;
29
32
  lastEditedFile?: string;
30
33
  };
31
34
 
@@ -48,8 +51,14 @@ type FileEntry = {
48
51
 
49
52
  const state: EmacsState = ((globalThis as any).__piEmacsExtensionState ??= {
50
53
  startedEmacsServer: false,
54
+ serverName: `pi-emacs-${process.pid}`,
51
55
  });
52
56
 
57
+ if (!state.serverName) {
58
+ state.legacyDefaultServer = state.startedEmacsServer;
59
+ state.serverName = `pi-emacs-${process.pid}`;
60
+ }
61
+
53
62
  const FILE_EXPLORER_MAX_VISIBLE = 8;
54
63
  const PROJECT_PICKER_MAX_VISIBLE = 12;
55
64
 
@@ -116,8 +125,151 @@ function execText(command: string, args: string[], options: SpawnOptions = {}) {
116
125
  });
117
126
  }
118
127
 
128
+ function emacsClientServerArgs(args: string[]) {
129
+ return ["-s", state.serverName, ...args];
130
+ }
131
+
119
132
  function emacsClient(args: string[], options: SpawnOptions = {}) {
120
- return run("emacsclient", args, options);
133
+ return run("emacsclient", emacsClientServerArgs(args), options);
134
+ }
135
+
136
+ function emacsClientSync(args: string[], timeoutMs = 5000) {
137
+ return spawnSync("emacsclient", emacsClientServerArgs(args), {
138
+ stdio: "ignore",
139
+ timeout: timeoutMs,
140
+ env: process.env,
141
+ });
142
+ }
143
+
144
+ function sleep(ms: number) {
145
+ return new Promise<void>((resolve) => setTimeout(resolve, ms));
146
+ }
147
+
148
+ function pidAlive(pid?: number) {
149
+ if (!pid) return false;
150
+ try {
151
+ process.kill(pid, 0);
152
+ return true;
153
+ } catch (error) {
154
+ return (error as NodeJS.ErrnoException).code === "EPERM";
155
+ }
156
+ }
157
+
158
+ function emacsWatchdogScript() {
159
+ return `
160
+ const { spawn, spawnSync } = require("node:child_process");
161
+ const parentPid = Number(process.argv[1]);
162
+ const serverName = process.argv[2];
163
+ const startDaemon = process.argv[3] === "1";
164
+ let stopping = false;
165
+ let child;
166
+ function parentAlive() {
167
+ try {
168
+ process.kill(parentPid, 0);
169
+ return true;
170
+ } catch (error) {
171
+ return error && error.code === "EPERM";
172
+ }
173
+ }
174
+ function killServer() {
175
+ spawnSync("emacsclient", ["-s", serverName, "--eval", "(kill-emacs)"], {
176
+ stdio: "ignore",
177
+ timeout: 5000,
178
+ env: process.env,
179
+ });
180
+ }
181
+ function stop() {
182
+ if (stopping) return;
183
+ stopping = true;
184
+ killServer();
185
+ try {
186
+ if (child && child.pid) process.kill(child.pid, "SIGTERM");
187
+ } catch {}
188
+ setTimeout(() => {
189
+ try {
190
+ if (child && child.pid) process.kill(child.pid, "SIGKILL");
191
+ } catch {}
192
+ process.exit(0);
193
+ }, 1000);
194
+ }
195
+ if (startDaemon) {
196
+ child = spawn("emacs", ["--fg-daemon=" + serverName], {
197
+ stdio: "ignore",
198
+ env: process.env,
199
+ });
200
+ child.on("error", () => process.exit(1));
201
+ child.on("exit", () => process.exit(0));
202
+ }
203
+ let ticks = 0;
204
+ setInterval(() => {
205
+ if (!parentAlive()) {
206
+ stop();
207
+ return;
208
+ }
209
+ if (!startDaemon && ++ticks % 10 === 0) {
210
+ const result = spawnSync("emacsclient", ["-s", serverName, "--eval", "(emacs-pid)"], {
211
+ stdio: "ignore",
212
+ timeout: 1000,
213
+ env: process.env,
214
+ });
215
+ if (result.status !== 0) process.exit(0);
216
+ }
217
+ }, 1000);
218
+ process.on("SIGTERM", stop);
219
+ process.on("SIGHUP", stop);
220
+ process.on("SIGINT", stop);
221
+ `;
222
+ }
223
+
224
+ type WatchdogMode = "start-daemon" | "watch-existing";
225
+
226
+ function startEmacsWatchdog(mode: WatchdogMode) {
227
+ const startDaemon = mode === "start-daemon";
228
+ if (!startDaemon && pidAlive(state.watchdogPid)) return;
229
+ const watchdog = spawn(
230
+ process.execPath,
231
+ [
232
+ "-e",
233
+ emacsWatchdogScript(),
234
+ String(process.pid),
235
+ state.serverName,
236
+ startDaemon ? "1" : "0",
237
+ ],
238
+ {
239
+ detached: true,
240
+ stdio: "ignore",
241
+ env: process.env,
242
+ },
243
+ );
244
+ state.watchdogPid = watchdog.pid;
245
+ watchdog.unref();
246
+ }
247
+
248
+ async function waitForEmacsServer(timeoutMs: number) {
249
+ const deadline = Date.now() + timeoutMs;
250
+ let lastError: unknown;
251
+ while (Date.now() < deadline) {
252
+ try {
253
+ await emacsClient(["--eval", "(emacs-pid)"], { timeoutMs: 1000 });
254
+ return;
255
+ } catch (error) {
256
+ lastError = error;
257
+ await sleep(250);
258
+ }
259
+ }
260
+ throw lastError instanceof Error
261
+ ? lastError
262
+ : new Error("emacs daemon did not start");
263
+ }
264
+
265
+ function stopLegacyDefaultServerIfNeeded() {
266
+ if (!state.legacyDefaultServer) return;
267
+ spawnSync("emacsclient", ["--eval", "(kill-emacs)"], {
268
+ stdio: "ignore",
269
+ timeout: 5000,
270
+ env: process.env,
271
+ });
272
+ state.legacyDefaultServer = false;
121
273
  }
122
274
 
123
275
  function withTerminalMouse(expression: string) {
@@ -144,14 +296,12 @@ function findFileExpression(filePath: string) {
144
296
  function diredExpression(cwd: string) {
145
297
  return withTerminalMouse(
146
298
  [
147
- `(let* ((dir ${JSON.stringify(cwd)})`,
148
- "(buf (dired-find-buffer-nocreate dir)))",
149
- "(if buf",
150
299
  "(progn",
151
- "(with-current-buffer buf",
152
- "(revert-buffer :ignore-auto :noconfirm))",
153
- "(switch-to-buffer buf))",
154
- "(dired dir)))",
300
+ "(mapc (lambda (b)",
301
+ "(when (eq (buffer-local-value 'major-mode b) 'dired-mode)",
302
+ "(kill-buffer b)))",
303
+ "(buffer-list))",
304
+ `(dired ${JSON.stringify(cwd)}))`,
155
305
  ].join(" "),
156
306
  );
157
307
  }
@@ -693,6 +843,7 @@ class ProjectFilePicker implements Component, Focusable {
693
843
  }
694
844
 
695
845
  async function projectFiles(cwd: string): Promise<string[]> {
846
+ const { rgPath } = await import("@vscode/ripgrep");
696
847
  const output = await execText(
697
848
  rgPath,
698
849
  ["--files", "--hidden", "--glob", "!.git/**"],
@@ -732,34 +883,60 @@ async function chooseProjectFile(
732
883
 
733
884
  async function ensureEmacsServer() {
734
885
  state.serverStartPromise ??= (async () => {
886
+ stopLegacyDefaultServerIfNeeded();
887
+
735
888
  try {
736
889
  await emacsClient(["--eval", "(emacs-pid)"], { timeoutMs: 2000 });
890
+ state.startedEmacsServer = true;
891
+ startEmacsWatchdog("watch-existing");
737
892
  return;
738
893
  } catch {
739
- // No reachable server. Start daemon below.
894
+ // No reachable named server. Start one under watchdog supervision.
740
895
  }
741
896
 
742
- console.log("[emacs] starting daemon...");
743
- await run("emacs", ["--daemon"], { timeoutMs: 15000 });
897
+ startEmacsWatchdog("start-daemon");
898
+ await waitForEmacsServer(15000);
744
899
  state.startedEmacsServer = true;
745
- console.log("[emacs] daemon started");
746
- })();
900
+ })().catch((error) => {
901
+ state.serverStartPromise = undefined;
902
+ throw error;
903
+ });
747
904
 
748
905
  return state.serverStartPromise;
749
906
  }
750
907
 
751
908
  async function stopEmacsServer() {
752
- if (!state.startedEmacsServer) return;
909
+ if (!state.startedEmacsServer && !state.serverStartPromise) return;
753
910
 
754
911
  try {
755
912
  await state.serverStartPromise;
756
913
  } catch {
757
- return;
914
+ // Start failure means there may be nothing to stop.
758
915
  }
759
916
 
760
- await emacsClient(["--eval", "(kill-emacs)"], { timeoutMs: 5000 });
917
+ try {
918
+ await emacsClient(["--eval", "(kill-emacs)"], { timeoutMs: 5000 });
919
+ } catch {
920
+ // Watchdog still handles parent-death cleanup if graceful stop fails.
921
+ }
761
922
  state.startedEmacsServer = false;
762
923
  state.serverStartPromise = undefined;
924
+ state.watchdogPid = undefined;
925
+ }
926
+
927
+ function stopEmacsServerSync() {
928
+ if (!state.startedEmacsServer && !state.serverStartPromise) return;
929
+ emacsClientSync(["--eval", "(kill-emacs)"]);
930
+ state.startedEmacsServer = false;
931
+ state.serverStartPromise = undefined;
932
+ state.watchdogPid = undefined;
933
+ }
934
+
935
+ function installProcessShutdownHandler() {
936
+ const handlerVersion = 2;
937
+ if (state.processShutdownHandlerVersion === handlerVersion) return;
938
+ state.processShutdownHandlerVersion = handlerVersion;
939
+ process.on("exit", () => stopEmacsServerSync());
763
940
  }
764
941
 
765
942
  async function openEmacsWithArgs(
@@ -831,6 +1008,8 @@ async function runProjectFindFile(ctx: ExtensionContext) {
831
1008
  }
832
1009
 
833
1010
  export default function (pi: ExtensionAPI) {
1011
+ installProcessShutdownHandler();
1012
+
834
1013
  pi.on("session_start", () => {
835
1014
  ensureEmacsServer().catch((error) => {
836
1015
  console.error(`[emacs] failed to start server: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liushihao456/pi-emacs",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Pi extension that allows switching to emacs seamlessly in a popup terminal.",
5
5
  "type": "module",
6
6
  "keywords": [