Skip to content

Commit d0d6cc6

Browse files
committed
feat(pty): forward user messages to local-mode Claude stdin
- Pipe stdin for local Claude processes so chat messages go directly to the running session instead of triggering a mode switch - Add stdin message dedup filter in local launcher scanner callback (guards against user messages without a `message` payload) - Restore lost messages to queue on claudeRemote launch failure - Export extractRawUserTextContent for reuse in dedup logic
1 parent 0929136 commit d0d6cc6

8 files changed

Lines changed: 118 additions & 14 deletions

File tree

cli/src/api/apiSession.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const SYSTEM_INJECTION_PREFIXES = [
5555
// hub's scrollback ring). The tail always holds the latest full-screen redraw.
5656
const AGENT_TERMINAL_LOCAL_BUFFER_BYTES = 256 * 1024
5757

58-
function extractRawUserTextContent(content: unknown): string | null {
58+
export function extractRawUserTextContent(content: unknown): string | null {
5959
if (typeof content === 'string') {
6060
return content
6161
}

cli/src/claude/claudeLocal.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export async function claudeLocal(opts: {
1919
claudeArgs?: string[]
2020
allowedTools?: string[]
2121
hookSettingsPath: string
22+
/** Called when the child process's stdin is ready for writing. */
23+
onStdinReady?: (write: (data: string) => void) => void
2224
}) {
2325

2426
// Ensure project directory exists
@@ -95,7 +97,8 @@ export async function claudeLocal(opts: {
9597
const claudeCommand = getDefaultClaudeCodePath();
9698
logger.debug(`[ClaudeLocal] Using claude executable: ${claudeCommand}`);
9799

98-
// Spawn the process
100+
// Spawn the process with pipe stdin so chat messages can be forwarded
101+
// to the running Claude process instead of triggering a mode switch.
99102
try {
100103
await spawnWithTerminalGuard({
101104
command: claudeCommand,
@@ -108,7 +111,9 @@ export async function claudeLocal(opts: {
108111
installHint: 'Claude CLI',
109112
includeCause: true,
110113
logExit: true,
111-
shell: false // Use absolute path, no shell needed
114+
shell: false, // Use absolute path, no shell needed
115+
stdio: ['pipe', 'inherit', 'inherit'],
116+
onSpawned: opts.onStdinReady
112117
});
113118
} finally {
114119
cleanupMcpConfig?.();

cli/src/claude/claudeLocalLauncher.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ function createSessionStub() {
5454
addSessionFoundCallback: () => {},
5555
removeSessionFoundCallback: () => {},
5656
consumeOneTimeFlags: () => {},
57-
recordLocalLaunchFailure: () => {}
57+
recordLocalLaunchFailure: () => {},
58+
stdinMessageTexts: new Set<string>()
5859
},
5960
sentMessages
6061
}
@@ -133,4 +134,23 @@ describe('claudeLocalLauncher message filtering', () => {
133134

134135
expect(sentMessages).toHaveLength(0)
135136
})
137+
138+
it('swallows exactly one stdin echo per forward, so a repeated identical message still surfaces', async () => {
139+
const { session, sentMessages } = createSessionStub()
140+
await claudeLocalLauncher(session as never)
141+
142+
// One "continue" was forwarded to claude via stdin (marked for dedup).
143+
session.stdinMessageTexts.add('continue')
144+
// Its JSONL echo is swallowed (already shown in chat via the web path)...
145+
harness.scannerOnMessage!({ type: 'user', uuid: '1', message: { content: 'continue' } })
146+
expect(sentMessages).toHaveLength(0)
147+
// ...and the dedup entry is consumed on match, bounding the set.
148+
expect(session.stdinMessageTexts.size).toBe(0)
149+
150+
// The user sends "continue" again; this echo has no pending forward to
151+
// swallow it, so it must surface (regression: a value-keyed, never-cleared
152+
// set would have silently dropped it).
153+
harness.scannerOnMessage!({ type: 'user', uuid: '2', message: { content: 'continue' } })
154+
expect(sentMessages).toHaveLength(1)
155+
})
136156
})

cli/src/claude/claudeLocalLauncher.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Session } from "./session";
33
import { createSessionScanner } from "./utils/sessionScanner";
44
import { isClaudeChatVisibleMessage } from "./utils/chatVisibility";
55
import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher";
6+
import { extractRawUserTextContent } from "@/api/apiSession";
67

78
export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> {
89

@@ -25,6 +26,19 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
2526
if (!isClaudeChatVisibleMessage(message)) {
2627
return
2728
}
29+
// Skip the JSONL echo of a user message we already forwarded to the
30+
// local process via stdin (it is already in the hub as a consumed
31+
// message from the web chat). Without this the same user text would
32+
// appear twice in the chat UI — once from the web path and once from
33+
// the JSONL transcript. Swallow exactly ONE echo per forward by
34+
// deleting on match: this both bounds the set and lets a later,
35+
// identical message ("yes", "continue", ...) surface normally.
36+
if (message.type === 'user') {
37+
const text = extractRawUserTextContent(message.message?.content)
38+
if (text && session.stdinMessageTexts.delete(text)) {
39+
return
40+
}
41+
}
2842
session.client.sendClaudeSessionMessage(message)
2943
}
3044
});
@@ -43,16 +57,28 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' |
4357
startedBy: session.startedBy,
4458
startingMode: session.startingMode,
4559
launch: async (abortSignal) => {
46-
await claudeLocal({
47-
path: session.path,
48-
sessionId: session.sessionId,
49-
abort: abortSignal,
50-
claudeEnvVars: session.claudeEnvVars,
51-
claudeArgs: session.claudeArgs,
52-
mcpServers: session.mcpServers,
53-
allowedTools: session.allowedTools,
54-
hookSettingsPath: session.hookSettingsPath,
55-
});
60+
session.writeStdin = null;
61+
try {
62+
await claudeLocal({
63+
path: session.path,
64+
sessionId: session.sessionId,
65+
abort: abortSignal,
66+
claudeEnvVars: session.claudeEnvVars,
67+
claudeArgs: session.claudeArgs,
68+
mcpServers: session.mcpServers,
69+
allowedTools: session.allowedTools,
70+
hookSettingsPath: session.hookSettingsPath,
71+
onStdinReady: (write) => {
72+
session.writeStdin = (data: string) => write(data);
73+
}
74+
});
75+
} finally {
76+
// The child has exited: drop the stdin writer so a message that
77+
// races the mode-flip isn't routed to a destroyed pipe (and then
78+
// acked as consumed but silently lost). onUserMessage falls back
79+
// to the queue once this is null.
80+
session.writeStdin = null;
81+
}
5682
},
5783
onLaunchSuccess: () => {
5884
session.consumeOneTimeFlags();

cli/src/claude/claudeRemoteLauncher.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
294294
this.abortFuture = new Future<void>();
295295
let modeHash: string | null = null;
296296
let mode: EnhancedMode | null = null;
297+
// Track the last message consumed from the queue so we can
298+
// restore it if claudeRemote throws before processing.
299+
let consumedMessage: { message: string; mode: EnhancedMode } | null = null;
297300
try {
298301
await claudeRemote({
299302
sessionId: session.sessionId,
@@ -331,6 +334,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
331334
}
332335
modeHash = msg.hash;
333336
mode = msg.mode;
337+
consumedMessage = msg;
334338
permissionHandler.handleModeChange(mode.permissionMode);
335339
return {
336340
message: msg.message,
@@ -356,6 +360,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
356360
session.clearSessionId();
357361
},
358362
onReady: () => {
363+
// Message was successfully processed, stop tracking.
364+
consumedMessage = null;
359365
logger.debug(
360366
`[claudeRemoteLauncher][async-debug] onReady callback ` +
361367
`(hasPending=${Boolean(pending)}, queueSize=${session.queue.size()})`
@@ -377,6 +383,23 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
377383
}
378384
} catch (e) {
379385
logger.debug('[remote]: launch error', e);
386+
// `consumedMessage` is only assigned inside the nextMessage
387+
// callback, which TS can't trace from this catch (it narrows
388+
// the var to null), so restore through a typed local.
389+
const restore = consumedMessage as { message: string; mode: EnhancedMode } | null;
390+
if (restore) {
391+
logger.debug('[remote]: restoring lost message to queue');
392+
// Restore via the public queue API so the waiter is
393+
// notified and the mode hash is recomputed — don't poke
394+
// the private backing array. NOTE: collectBatch already
395+
// dropped the consumed messages' localIds (only the
396+
// combined string survives), so the restored item is
397+
// localId-less; a later cancel-by-localId can't target it.
398+
// Full id restoration would require collectBatch to surface
399+
// the consumed ids.
400+
session.queue.unshift(restore.message, restore.mode);
401+
consumedMessage = null;
402+
}
380403
if (!this.exitReason) {
381404
const detail = e instanceof Error ? e.message : String(e);
382405
session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` });

cli/src/claude/runClaude.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,21 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
404404
return;
405405
}
406406

407+
// If in local mode with a live stdin pipe, forward the message
408+
// directly to the running Claude process instead of pushing to
409+
// the queue (which would trigger doSwitch and kill the process).
410+
const sessionInstance = currentSessionRef.current;
411+
if (sessionInstance?.writeStdin && sessionInstance.mode === 'local') {
412+
logger.debug('[start] forwarding message to local process stdin');
413+
sessionInstance.stdinMessageTexts.add(formattedText);
414+
sessionInstance.writeStdin(formattedText + '\n');
415+
if (localId) {
416+
session.emitMessagesConsumed([localId]);
417+
}
418+
logger.debugLargeJson('User message forwarded to local stdin:', message)
419+
return;
420+
}
421+
407422
// Push with resolved permission mode, model, system prompts, and tools
408423
const enhancedMode: EnhancedMode = {
409424
permissionMode: messagePermissionMode ?? 'default',

cli/src/claude/session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export class Session extends AgentSessionBase<EnhancedMode> {
2121
readonly startedBy: 'runner' | 'terminal';
2222
readonly startingMode: 'local' | 'remote';
2323
localLaunchFailure: LocalLaunchFailure | null = null;
24+
/** Function to write data to the local Claude process's stdin. */
25+
writeStdin: ((data: string) => void) | null = null;
26+
/** Texts of messages that were forwarded to local Claude via stdin.
27+
* Used by the sessionScanner to skip duplicate user messages. */
28+
readonly stdinMessageTexts: Set<string> = new Set();
2429

2530
constructor(opts: {
2631
api: ApiClient;

cli/src/utils/spawnWithAbort.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type SpawnWithAbortOptions = {
3131
shell?: SpawnOptions['shell'];
3232
stdio?: StdioOptions;
3333
windowsHide?: SpawnOptions['windowsHide'];
34+
/** Called after the child process is spawned with a function to write to stdin. */
35+
onSpawned?: (writeStdin: (data: string) => void) => void;
3436
};
3537

3638
export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise<void> {
@@ -57,6 +59,14 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise<vo
5759
windowsHide: options.windowsHide ?? process.platform === 'win32'
5860
});
5961

62+
if (options.onSpawned) {
63+
options.onSpawned((data: string) => {
64+
if (child.stdin && !child.stdin.destroyed) {
65+
child.stdin.write(data);
66+
}
67+
});
68+
}
69+
6070
let abortKillTimeout: NodeJS.Timeout | null = null;
6171

6272
const abortHandler = () => {

0 commit comments

Comments
 (0)