jeo-code 0.12.7 → 0.12.9

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.
@@ -44,6 +44,7 @@ import * as fs from "node:fs/promises";
44
44
  import type { Server, ServerWebSocket } from "bun";
45
45
  import { readGlobalConfig } from "../state";
46
46
  import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
47
+ import { resolveDiscordArtifactStage, type DiscordArtifactPaths } from "./discord-artifacts";
47
48
  import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
48
49
 
49
50
  const SNAPSHOT_POLL_MS = 1_200;
@@ -75,6 +76,13 @@ export class SessionNotifyEndpoint {
75
76
  private server: Server<undefined> | undefined;
76
77
 
77
78
  private readonly sockets = new Set<ServerWebSocket<undefined>>();
79
+ private readonly relaySockets = new Set<ServerWebSocket<undefined>>();
80
+ private readonly pendingRelay = new Map<ServerWebSocket<undefined>, number>();
81
+ private relayRequestGeneration = 0;
82
+ private readonly remoteControllers = new Map<ServerWebSocket<undefined>, { reqId: string; controller: AbortController }>();
83
+ private relayGeneration = 0;
84
+ private discordControlEnabled = false;
85
+ private discordControlGeneration = 0;
78
86
  private pollTimer: ReturnType<typeof setInterval> | undefined;
79
87
  private lastSnapshotJson = "";
80
88
  /** `undefined` between turns — subagent requests report an empty registry. */
@@ -85,6 +93,8 @@ export class SessionNotifyEndpoint {
85
93
 
86
94
  /** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
87
95
  onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
96
+ onRemoteRequest: ((text: string, finish: (text?: string, artifactsAllowed?: boolean) => void, signal: AbortSignal, stage?: DiscordArtifactPaths) => boolean) | undefined;
97
+ onRegistryMutation: (() => void) | undefined;
88
98
  onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
89
99
 
90
100
  constructor(
@@ -153,6 +163,61 @@ export class SessionNotifyEndpoint {
153
163
  this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
154
164
  }
155
165
 
166
+ /** Revoke current subscriptions before asking a daemon to change destination. */
167
+ requestLocalRelay(destination: string): number {
168
+ this.relaySockets.clear();
169
+ this.relayGeneration++;
170
+ this.pendingRelay.clear();
171
+ const generation = ++this.relayRequestGeneration;
172
+ const json = JSON.stringify({ type: "local_relay_control", sessionId: this.sessionId, destination, generation });
173
+ let sent = 0;
174
+ for (const ws of this.sockets) {
175
+ try { if (ws.send(json) > 0) { if (destination !== "off") this.pendingRelay.set(ws, generation); sent++; continue; } } catch {}
176
+ this.sockets.delete(ws);
177
+ this.remoteControllers.get(ws)?.controller.abort();
178
+ this.remoteControllers.delete(ws);
179
+ try { ws.terminate(); } catch { try { ws.close(); } catch {} }
180
+ }
181
+ return sent;
182
+ }
183
+
184
+ /** Snapshot eligibility before a turn; changes invalidate its relay. */
185
+ localRelayGeneration(): number | undefined {
186
+ return this.relaySockets.size ? this.relayGeneration : undefined;
187
+ }
188
+
189
+ sendLocalFinal(text: string, generation: number): void {
190
+ if (generation !== this.relayGeneration) return;
191
+ const json = JSON.stringify({ type: "local_final", sessionId: this.sessionId, text });
192
+ for (const ws of this.relaySockets) {
193
+ try { ws.send(json); } catch {}
194
+ }
195
+ }
196
+
197
+ private sendDiscordControl(ws: ServerWebSocket<undefined>): void {
198
+ try {
199
+ const frame = JSON.stringify({ type: "discord_control", sessionId: this.sessionId, enabled: this.discordControlEnabled, generation: this.discordControlGeneration });
200
+ if (ws.send(frame) > 0) return;
201
+ } catch {}
202
+ this.sockets.delete(ws);
203
+ this.pendingRelay.delete(ws);
204
+ if (this.relaySockets.delete(ws)) this.relayGeneration++;
205
+ this.remoteControllers.get(ws)?.controller.abort();
206
+ this.remoteControllers.delete(ws);
207
+ try { ws.terminate(); } catch { try { ws.close(); } catch {} }
208
+ }
209
+
210
+ setDiscordControlEnabled(enabled: boolean): void {
211
+ if (enabled === this.discordControlEnabled) return;
212
+ this.discordControlEnabled = enabled;
213
+ this.discordControlGeneration++;
214
+ for (const ws of this.sockets) this.sendDiscordControl(ws);
215
+ if (!enabled) {
216
+ for (const remote of this.remoteControllers.values()) remote.controller.abort();
217
+ this.remoteControllers.clear();
218
+ }
219
+ }
220
+
156
221
  private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
157
222
  if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
158
223
  let msg: Record<string, unknown>;
@@ -162,25 +227,71 @@ export class SessionNotifyEndpoint {
162
227
  return;
163
228
  }
164
229
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
165
- if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
230
+ if (msg.type !== "remote_request" && msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
231
+ if (msg.type === "remote_request") {
232
+ const reqId = msg.reqId;
233
+ let ok = false;
234
+ const onlyExpectedFields = Object.keys(msg).every(key => ["type", "sessionId", "reqId", "text", "staged", "controlGeneration"].includes(key));
235
+ if (onlyExpectedFields && this.discordControlEnabled && msg.controlGeneration === this.discordControlGeneration && msg.sessionId === this.sessionId && typeof reqId === "string" && reqId.length > 0 && reqId.length <= 256 &&
236
+ typeof msg.text === "string" && !!msg.text.trim() && !msg.text.includes("\u0003") && !/^[\/!$]/.test(msg.text.trimStart()) && (msg.staged === undefined || msg.staged === true) && this.onRemoteRequest && !this.remoteControllers.has(ws)) {
237
+ let finished = false;
238
+ const controlGeneration = this.discordControlGeneration;
239
+ const controller = new AbortController();
240
+ this.remoteControllers.set(ws, { reqId, controller });
241
+ let stage: DiscordArtifactPaths | undefined;
242
+ const finish = (text?: string, artifactsAllowed = false): void => {
243
+ if (finished) return;
244
+ finished = true;
245
+ if (this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
246
+ if (!this.sockets.has(ws)) return;
247
+ if (!this.discordControlEnabled || controller.signal.aborted || controlGeneration !== this.discordControlGeneration) text = undefined;
248
+ if (!stage) artifactsAllowed = false;
249
+ try { ws.send(JSON.stringify({ type: "remote_final", sessionId: this.sessionId, reqId, controlGeneration, ok: text !== undefined, artifactsAllowed: text !== undefined && artifactsAllowed, ...(text !== undefined ? { text } : {}) })); } catch {}
250
+ };
251
+ try {
252
+ if (msg.staged === true) stage = await resolveDiscordArtifactStage(this.sessionId, reqId);
253
+ if (this.discordControlEnabled && controlGeneration === this.discordControlGeneration && !controller.signal.aborted && this.sockets.has(ws)) {
254
+ ok = this.onRemoteRequest(msg.text, finish, controller.signal, stage);
255
+ }
256
+ } catch {}
257
+ if (!ok && this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
258
+ }
259
+ try { ws.send(JSON.stringify({ type: "ack", reqId, ok })); } catch {}
260
+ return;
261
+ }
262
+ if (msg.type === "remote_cancel" && msg.sessionId === this.sessionId && typeof msg.reqId === "string") {
263
+ const remote = this.remoteControllers.get(ws);
264
+ if (remote?.reqId === msg.reqId) remote.controller.abort();
265
+ return;
266
+ }
267
+ if (msg.type === "local_relay" && msg.sessionId === this.sessionId && typeof msg.enabled === "boolean") {
268
+ const allowed = !msg.enabled || (typeof msg.generation === "number" && this.pendingRelay.get(ws) === msg.generation && msg.generation === this.relayRequestGeneration);
269
+ if (allowed && msg.enabled) this.relaySockets.add(ws);
270
+ else if (!msg.enabled) this.relaySockets.delete(ws);
271
+ if (allowed) { this.pendingRelay.delete(ws); this.relayGeneration++; }
272
+ try { ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: allowed })); } catch {}
273
+ return;
274
+ }
166
275
  if (msg.type === "list") {
167
276
  try {
168
277
  ws.send(JSON.stringify(this.snapshot()));
169
278
  } catch {}
170
279
  return;
171
280
  }
172
- if (msg.type === "steer") {
281
+ if (msg.type === "steer" || msg.type === "discord_steer") {
173
282
  const id = typeof msg.id === "string" ? msg.id : "";
174
283
  const message = typeof msg.message === "string" ? msg.message : "";
175
- const ok = !!(id && message && this.registry?.steer(id, message));
284
+ const ok = !!((msg.type === "steer" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && id && message && this.registry?.steer(id, message));
285
+ if (ok) this.onRegistryMutation?.();
176
286
  try {
177
287
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
178
288
  } catch {}
179
289
  return;
180
290
  }
181
- if (msg.type === "cancel") {
291
+ if (msg.type === "cancel" || msg.type === "discord_cancel") {
182
292
  const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
183
- const cancelled = ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
293
+ const cancelled = (msg.type === "cancel" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
294
+ if (cancelled.length) this.onRegistryMutation?.();
184
295
  try {
185
296
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
186
297
  ws.send(JSON.stringify(this.snapshot()));
@@ -232,10 +343,15 @@ export class SessionNotifyEndpoint {
232
343
  ws.send(JSON.stringify(this.snapshot()));
233
344
  if (this.identityFrame) ws.send(this.identityFrame);
234
345
  } catch {}
346
+ this.sendDiscordControl(ws);
235
347
  },
236
348
  message: (ws, raw) => { void this.handleMessage(ws, raw); },
237
349
  close: ws => {
238
350
  this.sockets.delete(ws);
351
+ this.pendingRelay.delete(ws);
352
+ this.remoteControllers.get(ws)?.controller.abort();
353
+ this.remoteControllers.delete(ws);
354
+ if (this.relaySockets.delete(ws)) this.relayGeneration++;
239
355
  },
240
356
  },
241
357
  });
@@ -279,6 +395,12 @@ export class SessionNotifyEndpoint {
279
395
  clearInterval(this.pollTimer);
280
396
  this.pollTimer = undefined;
281
397
  }
398
+ this.setDiscordControlEnabled(false);
399
+ this.relaySockets.clear();
400
+ this.pendingRelay.clear();
401
+ this.relayGeneration++;
402
+ for (const remote of this.remoteControllers.values()) remote.controller.abort();
403
+ this.remoteControllers.clear();
282
404
  for (const ws of this.sockets) {
283
405
  try {
284
406
  ws.close();
@@ -76,7 +76,7 @@ export function newSessionId(): string {
76
76
  }
77
77
 
78
78
  export function sessionsDir(cwd = process.cwd()): string {
79
- return path.join(getLocalJeoDir(cwd), "sessions");
79
+ return process.env.JEO_SESSION_DIR || path.join(getLocalJeoDir(cwd), "sessions");
80
80
  }
81
81
 
82
82
  export function sessionPath(id: string, cwd = process.cwd()): string {
@@ -86,9 +86,11 @@ export function sessionPath(id: string, cwd = process.cwd()): string {
86
86
  export async function createSession(
87
87
  cwd = process.cwd(),
88
88
  id = newSessionId(),
89
- model?: string
89
+ model?: string,
90
+ directory = sessionsDir(cwd),
91
+ exclusive = false,
90
92
  ): Promise<{ id: string; path: string }> {
91
- const dir = sessionsDir(cwd);
93
+ const dir = directory;
92
94
  await fs.mkdir(dir, { recursive: true });
93
95
 
94
96
  const header: SessionHeader = {
@@ -100,8 +102,8 @@ export async function createSession(
100
102
  ...(model ? { model } : {}),
101
103
  };
102
104
 
103
- const file = sessionPath(id, cwd);
104
- await fs.writeFile(file, JSON.stringify(header) + "\n", "utf8");
105
+ const file = path.join(dir, `${id}.jsonl`);
106
+ await fs.writeFile(file, JSON.stringify(header) + "\n", { encoding: "utf8", ...(exclusive ? { flag: "wx", mode: 0o600 } : {}) });
105
107
 
106
108
  return { id, path: file };
107
109
  }
package/src/cli/runner.ts CHANGED
@@ -119,6 +119,15 @@ export const COMMANDS: readonly CommandSpec[] = [
119
119
  return args => m.runNotifyCommand(args);
120
120
  },
121
121
  },
122
+ {
123
+ name: "messenger",
124
+ summary: "Run or configure the bidirectional Discord, Telegram, and Slack agent gateway.",
125
+ usage: "messenger [setup|check|run] --config <file> [--channel ID] [--user ID] [--tools LIST] [--artifacts] [--service]",
126
+ loader: async () => {
127
+ const m = await import("../commands/messenger");
128
+ return args => m.runMessengerCommand(args);
129
+ },
130
+ },
122
131
  {
123
132
  name: "daemon",
124
133
  summary: "Manage the shared Telegram/Discord/Slack notification daemon.",
package/src/cli.ts CHANGED
@@ -44,6 +44,10 @@ const fatal = (err: unknown): never => {
44
44
  };
45
45
  process.on("uncaughtException", fatal);
46
46
  process.on("unhandledRejection", fatal);
47
+ // Bun 1.4 may report a closed downstream pipe as a stream error event instead of
48
+ // promoting it to uncaughtException. Keep both runtime shapes on the same quiet path.
49
+ process.stdout.on("error", fatal);
50
+ process.stderr.on("error", fatal);
47
51
 
48
52
  try {
49
53
  const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
@@ -9,13 +9,20 @@ export interface AgySessionTurnOptions extends Omit<AgyTurnOptions, "conversatio
9
9
  }
10
10
 
11
11
  /** An explicit full-agent turn: no raw-model loop, credential fallback, or tool replay. */
12
- export async function runAgySessionTurn(options: AgySessionTurnOptions): Promise<AgyTurnResult> {
12
+ export async function runAgySessionTurn(options: AgySessionTurnOptions, deps: {
13
+ loadSession?: typeof loadSession;
14
+ appendMessage?: typeof appendMessage;
15
+ appendAgySession?: typeof appendAgySession;
16
+ runAgyTurn?: typeof runAgyTurn;
17
+ } = {}): Promise<AgyTurnResult> {
13
18
  const { history, sessionId, images, ...turn } = options;
19
+ const { loadSession: load = loadSession, appendMessage: append = appendMessage, appendAgySession: appendAgy = appendAgySession, runAgyTurn: run = runAgyTurn } = deps;
14
20
  if (images?.length) throw new Error("Image attachments are not supported by the official agy bridge");
15
21
  if (turn.signal?.aborted) throw new AgyCliError("Official CLI turn cancelled", "cancelled");
16
22
 
17
23
  const previousCount = history.reduce((count, message) => count + (message.role === "system" ? 0 : 1), 0);
18
- const state = sessionId ? (await loadSession(sessionId, turn.cwd)).header.agy : undefined;
24
+ const state = sessionId ? (await load(sessionId, turn.cwd)).header.agy : undefined;
25
+ turn.signal?.throwIfAborted();
19
26
  // A non-CLI turn or compaction changes the cursor. Start fresh with text context
20
27
  // rather than resume a stale agent conversation or agy's unrelated latest one.
21
28
  let conversationId = state?.messageCount === previousCount ? state.conversationId : undefined;
@@ -33,26 +40,32 @@ export async function runAgySessionTurn(options: AgySessionTurnOptions): Promise
33
40
  }
34
41
 
35
42
  const user: Message = { role: "user", content: turn.prompt };
36
- if (sessionId) await appendMessage(sessionId, user, turn.cwd);
43
+ if (sessionId) await append(sessionId, user, turn.cwd);
44
+ turn.signal?.throwIfAborted();
37
45
  history.push(user);
38
- const result = await runAgyTurn({
46
+ const result = await run({
39
47
  ...turn,
40
48
  prompt,
41
49
  conversationId,
42
50
  onConversation: async id => {
51
+ turn.signal?.throwIfAborted();
43
52
  conversationId = id;
44
- if (sessionId) await appendAgySession(sessionId, { conversationId: id, messageCount: previousCount + 1 }, turn.cwd);
53
+ if (sessionId) await appendAgy(sessionId, { conversationId: id, messageCount: previousCount + 1 }, turn.cwd);
54
+ turn.signal?.throwIfAborted();
45
55
  },
46
56
  });
57
+ turn.signal?.throwIfAborted();
47
58
 
48
59
  const assistant: Message = { role: "assistant", content: result.response };
49
60
  try {
50
- if (sessionId) await appendMessage(sessionId, assistant, turn.cwd);
61
+ if (sessionId) await append(sessionId, assistant, turn.cwd);
62
+ turn.signal?.throwIfAborted();
51
63
  history.push(assistant);
52
- if (sessionId) await appendAgySession(sessionId, {
64
+ if (sessionId) await appendAgy(sessionId, {
53
65
  conversationId: result.conversationId,
54
66
  messageCount: previousCount + 2,
55
67
  }, turn.cwd);
68
+ turn.signal?.throwIfAborted();
56
69
  } catch (error) {
57
70
  throw new Error(`Official CLI completed, but saving its jeo session failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
58
71
  }
@@ -0,0 +1,78 @@
1
+ /** Confirm a sensitive action on an isolated local TTY; all other input denies. */
2
+ export async function confirmLocalControl({
3
+ input,
4
+ output,
5
+ timeoutMs = 15_000,
6
+ warning = "Discord control grants authorized users the host session's tools and approval policy, including reading or changing files and running commands. This is not a sandbox.",
7
+ prompt = "Confirm Discord control: press y to allow, any other key to cancel.",
8
+ }: {
9
+ input: NodeJS.ReadStream;
10
+ output: NodeJS.WriteStream;
11
+ timeoutMs?: number;
12
+ warning?: string;
13
+ prompt?: string;
14
+ }): Promise<boolean> {
15
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return false;
16
+ const wasRaw = !!input.isRaw;
17
+ const wasPaused = input.isPaused();
18
+ const dataListeners = input.rawListeners("data");
19
+ const keyListeners = input.rawListeners("keypress");
20
+ let deadline: ReturnType<typeof setTimeout> | undefined;
21
+ let quiet: ReturnType<typeof setTimeout> | undefined;
22
+ let phase: "drain" | "ready" | "candidate" = "drain";
23
+ let finished = false;
24
+ let resolveResult: (value: boolean) => void = () => {};
25
+ const result = new Promise<boolean>(resolve => { resolveResult = resolve; });
26
+ const finish = (accepted: boolean) => {
27
+ if (finished) return;
28
+ finished = true;
29
+ resolveResult(accepted);
30
+ };
31
+ const startQuietDrain = () => {
32
+ clearTimeout(quiet);
33
+ quiet = setTimeout(() => {
34
+ if (finished) return;
35
+ phase = "ready";
36
+ try { output.write(`${prompt}\n`); }
37
+ catch { finish(false); }
38
+ }, 200);
39
+ };
40
+ const onData = (chunk: string | Uint8Array) => {
41
+ if (phase === "drain") { startQuietDrain(); return; }
42
+ if (phase !== "ready" || (typeof chunk === "string" ? chunk !== "y" : chunk.length !== 1 || chunk[0] !== 0x79)) { finish(false); return; }
43
+ phase = "candidate";
44
+ quiet = setTimeout(() => finish(true), 200);
45
+ };
46
+ const onEnd = () => finish(false);
47
+ try {
48
+ input.pause();
49
+ for (const listener of dataListeners) input.removeListener("data", listener);
50
+ for (const listener of keyListeners) input.removeListener("keypress", listener);
51
+ input.setRawMode(true);
52
+ output.write(`${warning}\n`);
53
+ while (input.read() !== null) { /* discard input queued before the warning */ }
54
+ input.on("data", onData);
55
+ input.once("end", onEnd);
56
+ input.once("close", onEnd);
57
+ input.once("error", onEnd);
58
+ deadline = setTimeout(() => finish(false), timeoutMs);
59
+ startQuietDrain();
60
+ input.resume();
61
+ return await result;
62
+ } catch {
63
+ return false;
64
+ } finally {
65
+ clearTimeout(deadline);
66
+ clearTimeout(quiet);
67
+ input.pause();
68
+ input.removeListener("data", onData);
69
+ input.removeListener("end", onEnd);
70
+ input.removeListener("close", onEnd);
71
+ input.removeListener("error", onEnd);
72
+ for (const listener of dataListeners) input.on("data", listener);
73
+ for (const listener of keyListeners) input.on("keypress", listener);
74
+ try { input.setRawMode(wasRaw); } catch {}
75
+ if (wasPaused) input.pause();
76
+ else input.resume();
77
+ }
78
+ }
@@ -23,6 +23,8 @@ export interface LaunchFlags {
23
23
  thinking?: ThinkLevel;
24
24
  errors: string[];
25
25
  print?: boolean;
26
+ literalPrompt?: boolean;
27
+ literalWorker?: boolean;
26
28
  appendSystemPromptRaw?: string;
27
29
  appendSystemPrompt?: string;
28
30
  noSkills: boolean;
@@ -160,6 +162,11 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
160
162
  if (value) flags.skills = value;
161
163
  else flags.errors.push("--skills requires a value");
162
164
  i = nextIndex;
165
+ } else if (a === "--literal-prompt") {
166
+ flags.literalPrompt = true;
167
+ } else if (a === "--literal-worker") {
168
+ flags.literalWorker = true;
169
+ flags.literalPrompt = true;
163
170
  } else if (a === "--no-tools") {
164
171
  flags.noTools = true;
165
172
  } else if (a === "--tools" || a.startsWith("--tools=")) {
@@ -34,6 +34,9 @@ interface AbortHarnessOptions {
34
34
  onScrollKey?: (dir: -1 | 1, page: boolean) => void;
35
35
  /** Invoked with printable keyboard input received while the live turn owns stdin. */
36
36
  onBufferedInput?: (chunk: string) => void;
37
+ /** Emergency-only input while an aborted turn is still unwinding. */
38
+ onBufferedInputAfterAbort?: (chunk: string) => void;
39
+ pasteActiveAfterAbort?: () => boolean;
37
40
  /** True while the input queue is inside a bracketed paste (mid-paste chunks
38
41
  * carry no marker and must keep routing to the queue, not the noise path). */
39
42
  pasteActive?: () => boolean;
@@ -46,6 +49,15 @@ interface AbortHarnessOptions {
46
49
  export const PASTE_START = "\u001b[200~";
47
50
  export const PASTE_END = "\u001b[201~";
48
51
 
52
+ /** Conservative paste signal; one-character chunks remain indistinguishable from typing. */
53
+ export function isPasteLikeInputChunk(chunk: string): boolean {
54
+ let printable = 0;
55
+ for (const ch of stripPasteEscapes(chunk)) {
56
+ if ((ch >= " " || ch === "\t") && ++printable > 1) return true;
57
+ }
58
+ return false;
59
+ }
60
+
49
61
  /** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
50
62
  * standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
51
63
  * line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
@@ -483,8 +495,13 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
483
495
  };
484
496
 
485
497
  const handleData = (chunk: string | Uint8Array) => {
486
- if (!captureEsc || controller.signal.aborted) return;
498
+ if (!captureEsc) return;
487
499
  const raw = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
500
+ if (controller.signal.aborted) {
501
+ if (!raw.includes(PASTE_START) && !opts.pasteActiveAfterAbort?.() && chunkHasCtrlC(raw)) handleSigint();
502
+ else opts.onBufferedInputAfterAbort?.(raw);
503
+ return;
504
+ }
488
505
  if (raw.includes(PASTE_START) || opts.pasteActive?.()) {
489
506
  opts.onBufferedInput?.(raw);
490
507
  return;