@volter/supercode-terminal 0.2.61 → 0.2.63

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.
package/index.mjs CHANGED
@@ -1069,12 +1069,20 @@ export class PaneServerHost {
1069
1069
  }
1070
1070
 
1071
1071
  async #request(op, args, extra = {}) {
1072
- const connection = await this.#connect();
1073
- const id = this.#next++;
1074
- return new Promise((resolve, reject) => {
1075
- this.#pending.set(id, { resolve, reject });
1076
- connection.write(`${JSON.stringify({ id, op, args, ...extra })}\n`);
1077
- });
1072
+ // A server that is leaving refuses a request unrun (`pane_server_restarting`): it is asked again of the next one.
1073
+ for (let attempt = 0; ; attempt += 1) {
1074
+ const connection = await this.#connect();
1075
+ const id = this.#next++;
1076
+ try {
1077
+ return await new Promise((resolve, reject) => {
1078
+ this.#pending.set(id, { resolve, reject, connection });
1079
+ connection.write(`${JSON.stringify({ id, op, args, ...extra })}\n`);
1080
+ });
1081
+ } catch (error) {
1082
+ if (error?.code !== 'pane_server_restarting' || attempt >= 20) throw error;
1083
+ await sleep(250);
1084
+ }
1085
+ }
1078
1086
  }
1079
1087
 
1080
1088
  #connect() {
@@ -1124,15 +1132,27 @@ export class PaneServerHost {
1124
1132
  const waiter = this.#pending.get(message.id);
1125
1133
  if (!waiter) continue;
1126
1134
  this.#pending.delete(message.id);
1127
- if (message.error) waiter.reject(new TerminalHostError(message.error.code, message.error.message));
1135
+ if (message.error) {
1136
+ // The server ends after a runtime that failed to load: the next request opens a new connection.
1137
+ // The server is leaving: this client lets go of it, and its next request opens (or starts) the next one.
1138
+ if (message.error.code === 'pane_runtime_missing' || message.error.code === 'pane_server_restarting') {
1139
+ if (this.#connection === connection) this.#connection = null;
1140
+ connection.end();
1141
+ }
1142
+ waiter.reject(new TerminalHostError(message.error.code, message.error.message));
1143
+ }
1128
1144
  else waiter.resolve(message.result);
1129
1145
  }
1130
1146
  });
1131
1147
  // The server went (or this connection did): waiting requests fail, and viewers are told; the next call reconnects.
1132
1148
  connection.on('close', () => {
1133
1149
  if (this.#connection === connection) this.#connection = null;
1134
- for (const waiter of this.#pending.values()) waiter.reject(new TerminalHostError('pane_server_unreachable', 'The pane server connection closed.'));
1135
- this.#pending.clear();
1150
+ // Only the requests sent on this connection fail with it; one already replaced keeps the next one's requests.
1151
+ for (const [id, waiter] of this.#pending) {
1152
+ if (waiter.connection && waiter.connection !== connection) continue;
1153
+ this.#pending.delete(id);
1154
+ waiter.reject(new TerminalHostError('pane_server_unreachable', 'The pane server connection closed.'));
1155
+ }
1136
1156
  for (const relay of [...this.#streams.values()]) { relay.disposed = true; relay.socket?.close?.(1011, 'pane server connection closed'); }
1137
1157
  this.#streams.clear();
1138
1158
  });
@@ -1823,13 +1843,23 @@ function composerMatches(value, wanted) {
1823
1843
  return offsets.has(wanted.length);
1824
1844
  }
1825
1845
 
1846
+ /** Claude collapses a multi-line paste to `[Pasted text #N +K lines]`, and its composer then shows that placeholder,
1847
+ * never the text. It stands for `text` when K is the pasted text's line breaks (the trimmed text's, or the sent
1848
+ * payload's with its closing newline): a placeholder whose count differs is another paste. */
1849
+ function pastePlaceholderMatches(composer, text) {
1850
+ const placeholder=/^\[Pasted text #\d+ \+(\d+) lines?\]$/u.exec(String(composer??'').trim());
1851
+ if(!placeholder)return false;
1852
+ const breaks=value=>(value.match(/\n/gu)??[]).length;
1853
+ return [breaks(String(text).trim()),breaks(`${text}\n`)].includes(Number(placeholder[1]));
1854
+ }
1855
+
1826
1856
  /** Undo only text this input wrote, including when a turn started meanwhile.
1827
1857
  * Never Enter, Escape, or Ctrl-C: restoration must not submit or stop work. */
1828
1858
  export async function restoreComposerInput(host, sessionId, kind, text) {
1829
1859
  const read=async()=>composerText(kind,await host.capture(sessionId,{preserveEscapes:true}));
1830
1860
  let remaining=await read();
1831
1861
  if(remaining==='')return {restored:true};
1832
- if(!composerMatches(remaining,String(text).trim()))return {restored:false,reason:'composer-changed-or-unreadable'};
1862
+ if(!composerMatches(remaining,String(text).trim())&&!pastePlaceholderMatches(remaining,text))return {restored:false,reason:'composer-changed-or-unreadable'};
1833
1863
  const lines=remaining.split('\n').length+2;
1834
1864
  for(let line=0;line<lines;line++) {
1835
1865
  if(await read()!==remaining)return {restored:false,reason:'composer-changed',remaining};
@@ -1864,6 +1894,7 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
1864
1894
  };
1865
1895
  if(expired())return failed('deadline');
1866
1896
  const wanted=String(text).trim();
1897
+ const holds=composer=>composerMatches(composer,wanted)||pastePlaceholderMatches(composer,text);
1867
1898
  if(!wanted||/[\x00-\x08\x0b-\x1f\x7f]/u.test(text))return failed('unsupported-text');
1868
1899
  for(let check=0;check<2;check++) {
1869
1900
  const screen=await capture(),before=composerText(kind,screen);
@@ -1877,20 +1908,20 @@ export async function deliverComposerInput(host, sessionId, kind, text, { deadli
1877
1908
  let pasted=false;
1878
1909
  for(let attempt=0;attempt<10&&!expired();attempt++) {
1879
1910
  await sleep(100);
1880
- if(composerMatches(await read(),wanted)){pasted=true;break;}
1911
+ if(holds(await read())){pasted=true;break;}
1881
1912
  }
1882
1913
  if(!pasted)return failed('paste-unconfirmed');
1883
1914
  for(let attempt=0;attempt<3;attempt++) {
1884
1915
  if(expired())return failed('deadline');
1885
1916
  const screen=await capture();
1886
- if(!composerMatches(composerText(kind,screen),wanted))return failed('composer-changed');
1917
+ if(!holds(composerText(kind,screen)))return failed('composer-changed');
1887
1918
  if(expired())return failed('deadline');
1888
1919
  await host.sendInput(sessionId,'\r',{submitMode:'single',deadlineMs});
1889
1920
  for(let poll=0;poll<10&&!expired();poll++) {
1890
1921
  await sleep(100);
1891
1922
  const current=await read();
1892
1923
  if(current==='')return kind === 'claude-code' ? {delivered:true,delivery,path:'composer_submit'} : {delivered:false,reason:'native-transcript-confirmation-required',delivery};
1893
- if(!composerMatches(current,wanted))return failed('submission-unconfirmed');
1924
+ if(!holds(current))return failed('submission-unconfirmed');
1894
1925
  }
1895
1926
  }
1896
1927
  return failed('submit-unconfirmed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-terminal",
3
- "version": "0.2.61",
3
+ "version": "0.2.63",
4
4
  "type": "module",
5
5
  "description": "Optional leak-safe terminal and tmux host adapter for Volter Harness frontends",
6
6
  "exports": {
@@ -20,6 +20,18 @@ const clients = new Set();
20
20
  const IDLE_EXIT_MS = 60_000;
21
21
  let idleTimer = null;
22
22
 
23
+ let leaving = false;
24
+ /**
25
+ * Leave once no client holds a connection: each client closes its own on the answer that says the runtime is missing
26
+ * (or on a refusal while this server is leaving), so nothing it sends is lost between servers. A client that never
27
+ * closes (an older daemon) is ended after a few seconds.
28
+ */
29
+ function leave() {
30
+ const done = () => { host.dispose(); process.exit(0); };
31
+ if (!clients.size) done();
32
+ setTimeout(() => { server.close(); for (const connection of clients) connection.end(); setTimeout(done, 500); }, 3_000).unref?.();
33
+ }
34
+
23
35
  const OPS = new Set(['listSessions', 'createSession', 'capture', 'sendInput', 'closeSession', 'renameWindow', 'issueAttachment', 'bindContext']);
24
36
 
25
37
  function scheduleIdleExit() {
@@ -86,19 +98,23 @@ const server = createServer((connection) => {
86
98
  const { id, op, args = [] } = message;
87
99
  if (op === 'attach') { attach(id, args, message.stream); continue; }
88
100
  if (!OPS.has(op)) { refuse(id, { code: 'unsupported', message: `pane server: no operation ${op}` }); continue; }
101
+ // A server that is leaving runs nothing more: the request is refused unrun, and its client asks the next server.
102
+ if (leaving) { refuse(id, { code: 'pane_server_restarting', message: 'The pane server is restarting; ask again.' }); continue; }
89
103
  Promise.resolve().then(() => host[op](...args)).then((result) => answer(id, result), async (error) => {
90
- refuse(id, error);
91
- // The pane runtime failed to load in this process: with no pane to keep, it ends once the answer is out, so
104
+ // The pane runtime failed to load in this process: with no pane to keep, it ends once its answers are out, so
92
105
  // the next request starts a pane server that loads the runtime afresh (one installed since this one started).
93
- if (error?.code === 'pane_runtime_missing' && !(await host.listSessions().catch(() => [])).length) {
94
- connection.end(() => { host.dispose(); process.exit(0); });
95
- }
106
+ if (error?.code === 'pane_runtime_missing') leaving = true;
107
+ refuse(id, error);
108
+ if (!leaving) return;
109
+ if ((await host.listSessions().catch(() => [])).length) { leaving = false; return; }
110
+ leave();
96
111
  });
97
112
  }
98
113
  });
99
114
  // A client that goes (its daemon restarting) takes only its viewers with it: the panes stay.
100
115
  connection.on('close', () => {
101
116
  clients.delete(connection);
117
+ if (leaving && !clients.size) { host.dispose(); process.exit(0); }
102
118
  for (const { socketLike, attachment } of streams.values()) { socketLike.emitClose(); attachment?.dispose(); }
103
119
  streams.clear();
104
120
  scheduleIdleExit();