@adhdev/daemon-core 0.9.82-rc.417 → 0.9.82-rc.418

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.
@@ -156,6 +156,8 @@ export interface SpecDriverOpts {
156
156
  transportFactory?: PtyTransportFactory;
157
157
  extraCliArgs?: string[];
158
158
  }
159
+ export type Win32SubmitMode = 'paste' | 'soft_newline';
160
+ export declare function resolveWin32SubmitMode(env?: NodeJS.ProcessEnv): Win32SubmitMode;
159
161
  export declare function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number;
160
162
  /** Re-export of the shared surrogate-safe splitter so existing imports of
161
163
  * `chunkPreservingSurrogates` from this module keep working. The implementation
@@ -347,8 +349,21 @@ export declare class FsmDriver implements ISpecDriver {
347
349
  * single unbounded ConPTY write can overflow the input pipe and drop leading
348
350
  * bytes; splitting it with a short inter-chunk gap keeps the console input
349
351
  * buffer from overflowing. Small bodies still go out in a single write. Each
350
- * chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
351
- * the final chunk is out and echoed.
352
+ * write advances lastWin32WriteAt so the submit settle-gate keeps waiting until
353
+ * the final segment is out and echoed.
354
+ *
355
+ * FIX-B-v2: a body that contains an embedded newline cannot be written raw —
356
+ * on the real win32 Ink/ConPTY composer each '\n' SUBMITS the preceding line as
357
+ * its own entry, truncating the prompt to only the tail fragment. So a
358
+ * newline-bearing body is rewritten so its embedded newlines never submit:
359
+ * - 'paste' (default): wrap the body in a bracketed-paste (ESC[200~ … ESC[201~)
360
+ * — the composer takes the whole thing, newlines and all, as pasted text.
361
+ * - 'soft_newline': replace each embedded newline with a non-submitting
362
+ * Shift+Enter (CSI-u) so the body is typed as one multi-line entry.
363
+ * The trailing submit CR is NOT written here — it stays separate and is fired
364
+ * later by scheduleWin32Submit (concern (A)). The bracketed-paste markers are
365
+ * written as their own atomic segments (never chunked), so chunking can never
366
+ * split ESC[200~ / ESC[201~ mid-sequence regardless of body length.
352
367
  */
353
368
  private writeWin32Body;
354
369
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.417",
3
+ "version": "0.9.82-rc.418",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.417",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.418",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -152,21 +152,40 @@ function countNewlines(s: string): number {
152
152
 
153
153
  const SUBMIT_DELAY_FLOOR_MS = 200;
154
154
 
155
- // win32 ConPTY submit reliability. A MULTILINE message creates an Ink
156
- // paste/newline-accumulation window during which a lone CR is absorbed as a
157
- // literal newline instead of submitting; the window's length is
158
- // nondeterministic (observed 0–~2s, driven by ConPTY byte timing), so neither a
159
- // fixed delay nor a fixed CR count reliably submits. (A/B PTY testing on win32
160
- // ConPTY: single-line submits on the FIRST CR; multiline needs a *variable*
161
- // number of CRs as the window expires — a fixed double-CR fails outright, and
162
- // bracketed-paste wrapping does NOT help.) So we VERIFY instead of guessing:
163
- // write the text, then resend the submit key on a fixed cadence until the FSM
164
- // observes the agent has actually left the idle composer (status flips away from
165
- // 'idle' — i.e. it submitted and is generating / showing a modal), bounded by a
166
- // retry budget. Once submission is observed we stop so we don't spam Enter into
167
- // the next turn. Single-line messages satisfy the check after the first CR, so
168
- // their behaviour is unchanged. CRs absorbed as newlines during the window are
169
- // trimmed by the TUI on submit.
155
+ // win32 ConPTY submit reliability TWO independent concerns, do not conflate:
156
+ //
157
+ // (A) WHEN the single real submit CR fires. The first CR must not fire until
158
+ // the WHOLE body has echoed into the composer; a MULTILINE body also opens
159
+ // an Ink paste/newline-accumulation window during which a lone CR can be
160
+ // absorbed as a literal newline rather than submitting, with a
161
+ // nondeterministic length (observed 0–~2s, driven by ConPTY byte timing).
162
+ // So we VERIFY instead of guessing: hold the CR behind the head+tail
163
+ // echo-gate (scheduleWin32Submit phase 1), then resend the submit key on a
164
+ // fixed cadence until the FSM observes the agent has actually left the idle
165
+ // composer (status flips away from 'idle' — submitted / generating / modal),
166
+ // bounded by a retry budget (phase 2). Once submission is observed we stop
167
+ // so we don't spam Enter into the next turn. Single-line messages satisfy
168
+ // the check after the first CR, so their behaviour is unchanged.
169
+ //
170
+ // (B) HOW the body's OWN embedded newlines are written (FIX-B-v2). On the real
171
+ // win32 Ink/ConPTY composer each embedded '\n' in the body SUBMITS the
172
+ // preceding line as a separate composer entry, so writing the raw body
173
+ // (writeWin32Body) submitted every line but the last BEFORE the trailing
174
+ // echo-gated CR (A) ever ran — the prompt was truncated to only the tail
175
+ // fragment after the last '\n' (failure_category=per_newline_submit). The
176
+ // body must therefore land atomically as composer TEXT with ZERO per-line
177
+ // submits. writeWin32Body now does that: it wraps a newline-bearing body in
178
+ // a bracketed-paste (ESC[200~ … ESC[201~) so the composer takes the whole
179
+ // thing — embedded newlines and all — as pasted text (PRIMARY mode), or in
180
+ // the soft_newline fallback rewrites each embedded newline as a
181
+ // non-submitting Shift+Enter sequence. EITHER way the trailing submit CR is
182
+ // NOT part of this write — it stays separate and is fired later by (A).
183
+ //
184
+ // NOTE on the old "bracketed-paste wrapping does NOT help" claim that used to live
185
+ // here: that A/B fused the submit CR *inside* the paste (…body\r…201~), so the
186
+ // paste-closing still carried a submit and was never a clean text-only paste. The
187
+ // correct shape — paste wraps ONLY the body, CR stays separate — is what FIX-B-v2
188
+ // implements; it was never actually tested by that earlier A/B.
170
189
  const WIN32_SUBMIT_RESEND_GAP_MS = 350;
171
190
  const WIN32_SUBMIT_MAX_RESENDS = 14;
172
191
  // Quiet window the win32 echo-gate (below) requires AFTER the body is seen in the
@@ -190,6 +209,27 @@ const WIN32_SUBMIT_SETTLE_POLL_MS = 120;
190
209
  const WIN32_ECHO_PROBE_CHARS = 16;
191
210
  const WIN32_ECHO_MAX_WAIT_MS = 20_000;
192
211
 
212
+ // FIX-B-v2 — how a newline-bearing win32 body's OWN embedded newlines are written
213
+ // (see concern (B) above). 'paste' (default) wraps the body in a bracketed-paste so
214
+ // the Ink composer absorbs the whole thing as text; 'soft_newline' rewrites each
215
+ // embedded newline as a non-submitting Shift+Enter. Whether THIS ConPTY honors
216
+ // bracketed-paste can only be confirmed by the live deploy A/B (we cannot A/B it via
217
+ // delegation — win32 truncates any newline-bearing task), so both modes ship and the
218
+ // fallback is selectable at runtime via ADHDEV_WIN32_SUBMIT_MODE.
219
+ export type Win32SubmitMode = 'paste' | 'soft_newline';
220
+ const WIN32_BRACKETED_PASTE_OPEN = '\x1b[200~';
221
+ const WIN32_BRACKETED_PASTE_CLOSE = '\x1b[201~';
222
+ // Non-submitting soft-newline for the claude-cli Ink composer. The spec
223
+ // (cli/claude-cli/specs/4.0.json) declares no soft-newline keycode, so we use the
224
+ // CSI-u encoding of Shift+Enter (modifyOtherKeys form: keycode 13 = Enter, modifier
225
+ // 2 = Shift). This inserts a literal newline into the composer WITHOUT submitting,
226
+ // unlike a bare CR (\r) which is reserved for the single real submit via (A).
227
+ const WIN32_SOFT_NEWLINE = '\x1b[27;2;13~';
228
+
229
+ export function resolveWin32SubmitMode(env: NodeJS.ProcessEnv = process.env): Win32SubmitMode {
230
+ return env.ADHDEV_WIN32_SUBMIT_MODE === 'soft_newline' ? 'soft_newline' : 'paste';
231
+ }
232
+
193
233
  /** Collapse a string to its non-whitespace characters for echo comparison: the
194
234
  * composer wraps, indents, and prefixes the body (with the `❯ ` prompt), so a raw
195
235
  * substring test against the rendered screen fails. Stripping all whitespace makes
@@ -991,25 +1031,70 @@ export class FsmDriver implements ISpecDriver {
991
1031
  * single unbounded ConPTY write can overflow the input pipe and drop leading
992
1032
  * bytes; splitting it with a short inter-chunk gap keeps the console input
993
1033
  * buffer from overflowing. Small bodies still go out in a single write. Each
994
- * chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
995
- * the final chunk is out and echoed.
1034
+ * write advances lastWin32WriteAt so the submit settle-gate keeps waiting until
1035
+ * the final segment is out and echoed.
1036
+ *
1037
+ * FIX-B-v2: a body that contains an embedded newline cannot be written raw —
1038
+ * on the real win32 Ink/ConPTY composer each '\n' SUBMITS the preceding line as
1039
+ * its own entry, truncating the prompt to only the tail fragment. So a
1040
+ * newline-bearing body is rewritten so its embedded newlines never submit:
1041
+ * - 'paste' (default): wrap the body in a bracketed-paste (ESC[200~ … ESC[201~)
1042
+ * — the composer takes the whole thing, newlines and all, as pasted text.
1043
+ * - 'soft_newline': replace each embedded newline with a non-submitting
1044
+ * Shift+Enter (CSI-u) so the body is typed as one multi-line entry.
1045
+ * The trailing submit CR is NOT written here — it stays separate and is fired
1046
+ * later by scheduleWin32Submit (concern (A)). The bracketed-paste markers are
1047
+ * written as their own atomic segments (never chunked), so chunking can never
1048
+ * split ESC[200~ / ESC[201~ mid-sequence regardless of body length.
996
1049
  */
997
1050
  private writeWin32Body(text: string): void {
998
1051
  if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
999
- if (text.length <= WIN32_PTY_WRITE_CHUNK_CHARS) {
1052
+
1053
+ const hasNewline = /\r?\n/.test(text);
1054
+ const mode = resolveWin32SubmitMode();
1055
+
1056
+ // Build the ordered list of segments to write. Bracketed-paste markers are
1057
+ // their OWN segments so chunking only ever splits the body, never a marker.
1058
+ let segments: string[];
1059
+ if (!hasNewline) {
1060
+ // Single-line: unchanged behaviour — just (chunk and) write the body.
1061
+ segments = chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS);
1062
+ } else if (mode === 'soft_newline') {
1063
+ // Rewrite embedded newlines as non-submitting soft-newlines, THEN chunk.
1064
+ // The soft-newline sequence (ESC[27;2;13~) contains no '\n', so it is
1065
+ // never re-interpreted as a submit, and chunking it like ordinary text is
1066
+ // safe (a split mid-sequence is avoided below by chunking the whole
1067
+ // rewritten string — see the marker-safety note for paste; for soft_newline
1068
+ // the only ESC seq is short and self-contained, so we keep it simple and
1069
+ // chunk the rewritten body, accepting that the 1024-char chunk boundary is
1070
+ // astronomically unlikely to land inside a 9-byte CSI-u seq — and even if
1071
+ // it did, ConPTY reassembles the byte stream, the composer parses the full
1072
+ // sequence across the boundary).
1073
+ const rewritten = text.split(/\r?\n/).join(WIN32_SOFT_NEWLINE);
1074
+ segments = chunkPreservingSurrogates(rewritten, WIN32_PTY_WRITE_CHUNK_CHARS);
1075
+ } else {
1076
+ // paste: [OPEN marker] [body chunks…] [CLOSE marker]. Markers are atomic
1077
+ // segments — never merged with body bytes — so they cannot be split.
1078
+ segments = [
1079
+ WIN32_BRACKETED_PASTE_OPEN,
1080
+ ...chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS),
1081
+ WIN32_BRACKETED_PASTE_CLOSE,
1082
+ ];
1083
+ }
1084
+
1085
+ if (segments.length <= 1) {
1000
1086
  this.markWin32Write();
1001
- this.adapter.send_keys(text);
1087
+ this.adapter.send_keys(segments[0] ?? text);
1002
1088
  return;
1003
1089
  }
1004
- const chunks = chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS);
1005
1090
  let idx = 0;
1006
1091
  const writeNext = (): void => {
1007
1092
  this.win32WriteTimer = null;
1008
- if (idx >= chunks.length) return;
1093
+ if (idx >= segments.length) return;
1009
1094
  this.markWin32Write();
1010
- this.adapter.send_keys(chunks[idx]);
1095
+ this.adapter.send_keys(segments[idx]);
1011
1096
  idx += 1;
1012
- if (idx < chunks.length) {
1097
+ if (idx < segments.length) {
1013
1098
  this.win32WriteTimer = setTimeout(writeNext, WIN32_PTY_WRITE_CHUNK_GAP_MS);
1014
1099
  }
1015
1100
  };