@volter/supercode-terminal 0.2.39 → 0.2.41

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.mjs +65 -3
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execFile, spawn, spawnSync } from 'node:child_process';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
3
+ import { appendFileSync, cpSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
4
6
  import { connect as netConnect } from 'node:net';
5
7
  import { fileURLToPath } from 'node:url';
6
8
  import { createServer as defaultCreateServer } from 'node:http';
@@ -508,6 +510,18 @@ for (const event of ['end', 'close', 'error']) process.stdin.on(event, sweep);
508
510
  * disposing), so every pane here is this host's own. As with tmux's `remain-on-exit failed`, a pane whose program
509
511
  * fails stays listed and capturable, marked dead, until it is closed; one that exits cleanly leaves at once.
510
512
  */
513
+ /**
514
+ * A pane's end in the pane-exit log, as the tmux lane's hooks write it (`<time> <pane> closed`, or `died status=…
515
+ * signal=…` when it failed), under SUPERCODE_HOME or ~/.config/supercode: the machine daemon tells a clean finish from
516
+ * a failure by it, and a ConPTY pane that ended cleanly is otherwise gone without a trace.
517
+ */
518
+ function recordPaneExit(contextKey, exitCode, signal) {
519
+ if (!contextKey) return;
520
+ const at = new Date().toISOString().slice(0, 19);
521
+ const line = exitCode === 0 && !signal ? `${at} ${contextKey} closed\n` : `${at} ${contextKey} died status=${exitCode ?? ''} signal=${signal || ''}\n`;
522
+ try { appendFileSync(join(process.env.SUPERCODE_HOME || join(homedir(), '.config', 'supercode'), 'pane-exits.log'), line); } catch { /* no log is no worse than before */ }
523
+ }
524
+
511
525
  export class PtyTerminalHost {
512
526
  #attachments = new Map();
513
527
  #liveAttachments = new Set();
@@ -584,6 +598,7 @@ export class PtyTerminalHost {
584
598
  // server) outlives it, as it outlives a tmux pane. What stayed in the pane's console ends with the console.
585
599
  // Only closing a pane, or its daemon dying, ends a whole tree.
586
600
  releasePane(child);
601
+ recordPaneExit(entry.contextKey, exitCode, signal);
587
602
  this.#tellExit(entry);
588
603
  this.#writeWatchList();
589
604
  if (exitCode === 0) this.#forget(entry);
@@ -904,6 +919,20 @@ export class PtyTerminalHost {
904
919
  * connection; the panes and their programs go on, and the next daemon finds them there. The server starts on first
905
920
  * use, detached, and leaves a minute after it holds no pane and no client.
906
921
  */
922
+ /** What the pane server loads beyond this package (PtyTerminalHost's dynamic imports). */
923
+ const PANE_RUNTIME_PACKAGES = ['@homebridge/node-pty-prebuilt-multiarch', '@xterm/headless', '@xterm/addon-serialize'];
924
+
925
+ /** A dependency's package directory: the nearest one above its entry named for it (ESM resolution, as it is loaded). */
926
+ function packageDir(name) {
927
+ let dir = dirname(fileURLToPath(import.meta.resolve(name)));
928
+ for (;;) {
929
+ try { if (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name === name) return dir; } catch { /* not a package root */ }
930
+ const up = dirname(dir);
931
+ if (up === dir) throw new Error(`no package directory for ${name}`);
932
+ dir = up;
933
+ }
934
+ }
935
+
907
936
  export class PaneServerHost {
908
937
  #connection = null;
909
938
  #connecting = null;
@@ -912,11 +941,44 @@ export class PaneServerHost {
912
941
  #streams = new Map();
913
942
  #pipe;
914
943
  #serverScript;
944
+ #runtimeDir;
915
945
 
946
+ /**
947
+ * `runtimeDir`: where the pane server runs from, a copy of this package and its runtime dependencies per version
948
+ * (`<runtimeDir>/<version>`). A running pane server holds node-pty's native module open, and Windows cannot replace
949
+ * a file in use, so a server started from the installed package would stop the next install of it; started from its
950
+ * own copy, it leaves the installed package free to change under it. Without one, it runs from the package.
951
+ */
916
952
  constructor(options = {}) {
917
953
  if (!options.pipe) throw new TypeError('PaneServerHost requires a pipe name');
918
954
  this.#pipe = options.pipe;
919
- this.#serverScript = options.serverScript ?? fileURLToPath(new URL('./substrate/pane-server.mjs', import.meta.url));
955
+ this.#serverScript = options.serverScript ?? null;
956
+ this.#runtimeDir = options.runtimeDir ?? null;
957
+ }
958
+
959
+ /** The pane server's script: its own versioned copy under runtimeDir, made once, else the package's. */
960
+ #script() {
961
+ if (this.#serverScript) return this.#serverScript;
962
+ const installed = fileURLToPath(new URL('./substrate/pane-server.mjs', import.meta.url));
963
+ if (!this.#runtimeDir) return installed;
964
+ const root = fileURLToPath(new URL('.', import.meta.url));
965
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
966
+ const target = join(this.#runtimeDir, version);
967
+ const script = join(target, 'node_modules', '@volter', 'supercode-terminal', 'substrate', 'pane-server.mjs');
968
+ if (existsSync(join(target, '.complete'))) return script;
969
+ try {
970
+ const staging = `${target}.${process.pid}.staging`;
971
+ rmSync(staging, { recursive: true, force: true });
972
+ cpSync(root, join(staging, 'node_modules', '@volter', 'supercode-terminal'), { recursive: true, filter: (path) => !path.slice(root.length).split(/[\\/]/).includes('node_modules') });
973
+ for (const name of PANE_RUNTIME_PACKAGES) cpSync(packageDir(name), join(staging, 'node_modules', ...name.split('/')), { recursive: true });
974
+ writeFileSync(join(staging, '.complete'), `${version}\n`);
975
+ rmSync(target, { recursive: true, force: true });
976
+ renameSync(staging, target);
977
+ return script;
978
+ } catch {
979
+ // A copy that cannot be made (a full disk, a racing daemon) leaves the package's own script, as before.
980
+ return existsSync(join(target, '.complete')) ? script : installed;
981
+ }
920
982
  }
921
983
 
922
984
  listSessions() { return this.#request('listSessions', []); }
@@ -998,7 +1060,7 @@ export class PaneServerHost {
998
1060
  } catch (error) {
999
1061
  // No server on the pipe yet: start one, detached from this daemon, and try again as it comes up.
1000
1062
  if (attempt === 0 && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
1001
- const child = spawn(process.execPath, [this.#serverScript, this.#pipe], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env });
1063
+ const child = spawn(process.execPath, [this.#script(), this.#pipe], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env });
1002
1064
  child.on('error', () => {});
1003
1065
  child.unref();
1004
1066
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {