claude-remote-cli 2.15.4 → 2.15.6
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/dist/frontend/assets/index-6brRnAUY.js +47 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server/index.js +18 -6
- package/dist/server/mobile-input-pipeline.js +107 -0
- package/dist/server/sessions.js +161 -7
- package/dist/test/mobile-input.test.js +163 -0
- package/dist/test/sessions.test.js +153 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-yU3_SO20.js +0 -47
package/dist/frontend/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<meta name="theme-color" content="#1a1a1a" />
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-6brRnAUY.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-t15zfL9Q.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
package/dist/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import cookieParser from 'cookie-parser';
|
|
|
11
11
|
import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensureMetaDir } from './config.js';
|
|
12
12
|
import * as auth from './auth.js';
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
|
-
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS } from './sessions.js';
|
|
14
|
+
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames } from './sessions.js';
|
|
15
15
|
import { setupWebSocket } from './ws.js';
|
|
16
16
|
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
17
17
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
@@ -223,6 +223,12 @@ async function main() {
|
|
|
223
223
|
watcher.rebuild(config.rootDirs || []);
|
|
224
224
|
const server = http.createServer(app);
|
|
225
225
|
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
|
|
226
|
+
// Restore sessions from a previous update restart
|
|
227
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
228
|
+
const restoredCount = await restoreFromDisk(configDir);
|
|
229
|
+
if (restoredCount > 0) {
|
|
230
|
+
console.log(`Restored ${restoredCount} session(s) from previous update.`);
|
|
231
|
+
}
|
|
226
232
|
// Push notifications on session idle
|
|
227
233
|
sessions.onIdleChange((sessionId, idle) => {
|
|
228
234
|
if (idle) {
|
|
@@ -955,6 +961,11 @@ async function main() {
|
|
|
955
961
|
try {
|
|
956
962
|
await execFileAsync('npm', ['install', '-g', 'claude-remote-cli@latest']);
|
|
957
963
|
const restarting = serviceIsInstalled();
|
|
964
|
+
if (restarting) {
|
|
965
|
+
// Persist sessions so they can be restored after restart
|
|
966
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
967
|
+
serializeAll(configDir);
|
|
968
|
+
}
|
|
958
969
|
res.json({ ok: true, restarting });
|
|
959
970
|
if (restarting) {
|
|
960
971
|
setTimeout(() => process.exit(0), 1000);
|
|
@@ -965,15 +976,16 @@ async function main() {
|
|
|
965
976
|
res.status(500).json({ ok: false, error: message });
|
|
966
977
|
}
|
|
967
978
|
});
|
|
968
|
-
// Clean up orphaned tmux sessions from previous runs
|
|
979
|
+
// Clean up orphaned tmux sessions from previous runs (skip any adopted by restore)
|
|
969
980
|
try {
|
|
981
|
+
const adoptedNames = activeTmuxSessionNames();
|
|
970
982
|
const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', '#{session_name}']);
|
|
971
|
-
const
|
|
972
|
-
for (const name of
|
|
983
|
+
const orphanedSessions = stdout.trim().split('\n').filter(name => name.startsWith('crc-') && !adoptedNames.has(name));
|
|
984
|
+
for (const name of orphanedSessions) {
|
|
973
985
|
execFileAsync('tmux', ['kill-session', '-t', name]).catch(() => { });
|
|
974
986
|
}
|
|
975
|
-
if (
|
|
976
|
-
console.log(`Cleaned up ${
|
|
987
|
+
if (orphanedSessions.length > 0) {
|
|
988
|
+
console.log(`Cleaned up ${orphanedSessions.length} orphaned tmux session(s).`);
|
|
977
989
|
}
|
|
978
990
|
}
|
|
979
991
|
catch {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export function codepointCount(str) {
|
|
2
|
+
let count = 0;
|
|
3
|
+
for (let i = 0; i < str.length; i++) {
|
|
4
|
+
count++;
|
|
5
|
+
if (str.charCodeAt(i) >= 0xd800 && str.charCodeAt(i) <= 0xdbff)
|
|
6
|
+
i++;
|
|
7
|
+
}
|
|
8
|
+
return count;
|
|
9
|
+
}
|
|
10
|
+
export function commonPrefixLength(a, b) {
|
|
11
|
+
let len = 0;
|
|
12
|
+
while (len < a.length && len < b.length && a[len] === b[len])
|
|
13
|
+
len++;
|
|
14
|
+
return len;
|
|
15
|
+
}
|
|
16
|
+
const DEL = '\x7f';
|
|
17
|
+
function makeBackspaces(count) {
|
|
18
|
+
let s = '';
|
|
19
|
+
for (let i = 0; i < count; i++)
|
|
20
|
+
s += DEL;
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
function handleInsert(intent, currentValue) {
|
|
24
|
+
const { rangeStart, rangeEnd, data } = intent;
|
|
25
|
+
if (rangeStart !== null && rangeEnd !== null && rangeStart !== rangeEnd) {
|
|
26
|
+
// Non-collapsed range = autocorrect replacement
|
|
27
|
+
const replaced = intent.valueBefore.slice(rangeStart, rangeEnd);
|
|
28
|
+
const charsToDelete = codepointCount(replaced);
|
|
29
|
+
const payload = makeBackspaces(charsToDelete) + (data ?? '');
|
|
30
|
+
return { payload };
|
|
31
|
+
}
|
|
32
|
+
if (data) {
|
|
33
|
+
// Detect bad cursor-0 autocorrect: keyboard lost cursor position
|
|
34
|
+
// and prepended data at position 0 instead of replacing a word.
|
|
35
|
+
if (data.length > 1 && intent.cursorBefore === 0 &&
|
|
36
|
+
intent.valueBefore.length > 0 &&
|
|
37
|
+
currentValue === data + intent.valueBefore) {
|
|
38
|
+
const charsToDelete = codepointCount(intent.valueBefore);
|
|
39
|
+
const payload = makeBackspaces(charsToDelete) + data;
|
|
40
|
+
return { payload, newInputValue: data };
|
|
41
|
+
}
|
|
42
|
+
// Collapsed range = normal character insertion
|
|
43
|
+
return { payload: data };
|
|
44
|
+
}
|
|
45
|
+
// No data and no range — fall back to diff
|
|
46
|
+
return handleFallbackDiff(intent, currentValue);
|
|
47
|
+
}
|
|
48
|
+
function handleDelete(intent, currentValue) {
|
|
49
|
+
const { rangeStart, rangeEnd, valueBefore } = intent;
|
|
50
|
+
if (rangeStart !== null && rangeEnd !== null) {
|
|
51
|
+
const deleted = valueBefore.slice(rangeStart, rangeEnd);
|
|
52
|
+
const charsToDelete = codepointCount(deleted);
|
|
53
|
+
return { payload: makeBackspaces(charsToDelete) };
|
|
54
|
+
}
|
|
55
|
+
// No range info — diff to figure out how many chars were deleted
|
|
56
|
+
const deleted = valueBefore.length - currentValue.length;
|
|
57
|
+
const charsToDelete = Math.max(1, deleted);
|
|
58
|
+
return { payload: makeBackspaces(charsToDelete) };
|
|
59
|
+
}
|
|
60
|
+
function handleReplacement(intent, currentValue) {
|
|
61
|
+
const { rangeStart, rangeEnd, data, valueBefore } = intent;
|
|
62
|
+
if (rangeStart !== null && rangeEnd !== null) {
|
|
63
|
+
const replaced = valueBefore.slice(rangeStart, rangeEnd);
|
|
64
|
+
const charsToDelete = codepointCount(replaced);
|
|
65
|
+
const payload = makeBackspaces(charsToDelete) + (data ?? '');
|
|
66
|
+
return { payload };
|
|
67
|
+
}
|
|
68
|
+
return handleFallbackDiff(intent, currentValue);
|
|
69
|
+
}
|
|
70
|
+
function handlePaste(intent, currentValue) {
|
|
71
|
+
const commonLen = commonPrefixLength(intent.valueBefore, currentValue);
|
|
72
|
+
const pasted = currentValue.slice(commonLen);
|
|
73
|
+
return { payload: pasted };
|
|
74
|
+
}
|
|
75
|
+
function handleFallbackDiff(intent, currentValue) {
|
|
76
|
+
const valueBefore = intent.valueBefore || '';
|
|
77
|
+
if (currentValue === valueBefore) {
|
|
78
|
+
return { payload: '' };
|
|
79
|
+
}
|
|
80
|
+
const commonLen = commonPrefixLength(valueBefore, currentValue);
|
|
81
|
+
const deletedSlice = valueBefore.slice(commonLen);
|
|
82
|
+
const charsToDelete = codepointCount(deletedSlice);
|
|
83
|
+
const newChars = currentValue.slice(commonLen);
|
|
84
|
+
const payload = makeBackspaces(charsToDelete) + newChars;
|
|
85
|
+
return { payload };
|
|
86
|
+
}
|
|
87
|
+
export function processIntent(intent, currentValue) {
|
|
88
|
+
switch (intent.type) {
|
|
89
|
+
case 'insertText':
|
|
90
|
+
return handleInsert(intent, currentValue);
|
|
91
|
+
case 'deleteContentBackward':
|
|
92
|
+
case 'deleteContentForward':
|
|
93
|
+
case 'deleteWordBackward':
|
|
94
|
+
case 'deleteWordForward':
|
|
95
|
+
case 'deleteSoftLineBackward':
|
|
96
|
+
case 'deleteSoftLineForward':
|
|
97
|
+
case 'deleteBySoftwareKeyboard':
|
|
98
|
+
return handleDelete(intent, currentValue);
|
|
99
|
+
case 'insertReplacementText':
|
|
100
|
+
return handleReplacement(intent, currentValue);
|
|
101
|
+
case 'insertFromPaste':
|
|
102
|
+
case 'insertFromDrop':
|
|
103
|
+
return handlePaste(intent, currentValue);
|
|
104
|
+
default:
|
|
105
|
+
return handleFallbackDiff(intent, currentValue);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/server/sessions.js
CHANGED
|
@@ -4,7 +4,9 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
7
8
|
import { readMeta, writeMeta } from './config.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
8
10
|
const AGENT_COMMANDS = {
|
|
9
11
|
claude: 'claude',
|
|
10
12
|
codex: 'codex',
|
|
@@ -17,6 +19,7 @@ const AGENT_YOLO_ARGS = {
|
|
|
17
19
|
claude: ['--dangerously-skip-permissions'],
|
|
18
20
|
codex: ['--full-auto'],
|
|
19
21
|
};
|
|
22
|
+
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
20
23
|
function generateTmuxSessionName(displayName, id) {
|
|
21
24
|
const sanitized = displayName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 30);
|
|
22
25
|
return `crc-${sanitized}-${id.slice(0, 8)}`;
|
|
@@ -42,8 +45,8 @@ const idleChangeCallbacks = [];
|
|
|
42
45
|
function onIdleChange(cb) {
|
|
43
46
|
idleChangeCallbacks.push(cb);
|
|
44
47
|
}
|
|
45
|
-
function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux }) {
|
|
46
|
-
const id = crypto.randomBytes(8).toString('hex');
|
|
48
|
+
function create({ id: providedId, type, agent = 'claude', repoName, repoPath, cwd, root, worktreeName, branchName, displayName, command, args = [], cols = 80, rows = 24, configPath, useTmux: paramUseTmux, initialScrollback }) {
|
|
49
|
+
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
47
50
|
const createdAt = new Date().toISOString();
|
|
48
51
|
const resolvedCommand = command || AGENT_COMMANDS[agent];
|
|
49
52
|
// Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
|
|
@@ -66,9 +69,10 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
|
|
|
66
69
|
env,
|
|
67
70
|
});
|
|
68
71
|
// Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
|
|
69
|
-
const scrollback = [];
|
|
70
|
-
let scrollbackBytes = 0;
|
|
72
|
+
const scrollback = initialScrollback ? [...initialScrollback] : [];
|
|
73
|
+
let scrollbackBytes = initialScrollback ? initialScrollback.reduce((sum, s) => sum + s.length, 0) : 0;
|
|
71
74
|
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
75
|
+
const resolvedCwd = cwd || repoPath;
|
|
72
76
|
const session = {
|
|
73
77
|
id,
|
|
74
78
|
type: type || 'worktree',
|
|
@@ -84,6 +88,8 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
|
|
|
84
88
|
lastActivity: createdAt,
|
|
85
89
|
scrollback,
|
|
86
90
|
idle: false,
|
|
91
|
+
cwd: resolvedCwd,
|
|
92
|
+
customCommand: command || null,
|
|
87
93
|
useTmux,
|
|
88
94
|
tmuxSessionName,
|
|
89
95
|
onPtyReplacedCallbacks: [],
|
|
@@ -194,14 +200,14 @@ function create({ type, agent = 'claude', repoName, repoPath, cwd, root, worktre
|
|
|
194
200
|
});
|
|
195
201
|
}
|
|
196
202
|
attachHandlers(ptyProcess, continueArgs.some(a => args.includes(a)));
|
|
197
|
-
return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, useTmux, tmuxSessionName };
|
|
203
|
+
return { id, type: session.type, agent: session.agent, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, branchName: session.branchName, displayName: session.displayName, pid: ptyProcess.pid, createdAt, lastActivity: createdAt, idle: false, cwd: resolvedCwd, customCommand: command || null, useTmux, tmuxSessionName };
|
|
198
204
|
}
|
|
199
205
|
function get(id) {
|
|
200
206
|
return sessions.get(id);
|
|
201
207
|
}
|
|
202
208
|
function list() {
|
|
203
209
|
return Array.from(sessions.values())
|
|
204
|
-
.map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, useTmux, tmuxSessionName }) => ({
|
|
210
|
+
.map(({ id, type, agent, root, repoName, repoPath, worktreeName, branchName, displayName, createdAt, lastActivity, idle, cwd, customCommand, useTmux, tmuxSessionName }) => ({
|
|
205
211
|
id,
|
|
206
212
|
type,
|
|
207
213
|
agent,
|
|
@@ -214,6 +220,8 @@ function list() {
|
|
|
214
220
|
createdAt,
|
|
215
221
|
lastActivity,
|
|
216
222
|
idle,
|
|
223
|
+
cwd,
|
|
224
|
+
customCommand,
|
|
217
225
|
useTmux,
|
|
218
226
|
tmuxSessionName,
|
|
219
227
|
}))
|
|
@@ -264,4 +272,150 @@ function findRepoSession(repoPath) {
|
|
|
264
272
|
function nextTerminalName() {
|
|
265
273
|
return `Terminal ${++terminalCounter}`;
|
|
266
274
|
}
|
|
267
|
-
|
|
275
|
+
function serializeAll(configDir) {
|
|
276
|
+
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
277
|
+
fs.mkdirSync(scrollbackDirPath, { recursive: true });
|
|
278
|
+
const serialized = [];
|
|
279
|
+
for (const session of sessions.values()) {
|
|
280
|
+
// Write scrollback to disk
|
|
281
|
+
const scrollbackPath = path.join(scrollbackDirPath, session.id + '.buf');
|
|
282
|
+
fs.writeFileSync(scrollbackPath, session.scrollback.join(''), 'utf-8');
|
|
283
|
+
serialized.push({
|
|
284
|
+
id: session.id,
|
|
285
|
+
type: session.type,
|
|
286
|
+
agent: session.agent,
|
|
287
|
+
root: session.root,
|
|
288
|
+
repoName: session.repoName,
|
|
289
|
+
repoPath: session.repoPath,
|
|
290
|
+
worktreeName: session.worktreeName,
|
|
291
|
+
branchName: session.branchName,
|
|
292
|
+
displayName: session.displayName,
|
|
293
|
+
createdAt: session.createdAt,
|
|
294
|
+
lastActivity: session.lastActivity,
|
|
295
|
+
useTmux: session.useTmux,
|
|
296
|
+
tmuxSessionName: session.tmuxSessionName,
|
|
297
|
+
customCommand: session.customCommand,
|
|
298
|
+
cwd: session.cwd,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const pending = {
|
|
302
|
+
version: 1,
|
|
303
|
+
timestamp: new Date().toISOString(),
|
|
304
|
+
sessions: serialized,
|
|
305
|
+
};
|
|
306
|
+
fs.writeFileSync(path.join(configDir, 'pending-sessions.json'), JSON.stringify(pending, null, 2), 'utf-8');
|
|
307
|
+
}
|
|
308
|
+
async function restoreFromDisk(configDir) {
|
|
309
|
+
const pendingPath = path.join(configDir, 'pending-sessions.json');
|
|
310
|
+
if (!fs.existsSync(pendingPath))
|
|
311
|
+
return 0;
|
|
312
|
+
let pending;
|
|
313
|
+
try {
|
|
314
|
+
pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
fs.unlinkSync(pendingPath);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
// Ignore stale files (>5 minutes old)
|
|
321
|
+
if (Date.now() - new Date(pending.timestamp).getTime() > STALE_THRESHOLD_MS) {
|
|
322
|
+
fs.unlinkSync(pendingPath);
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
const scrollbackDirPath = path.join(configDir, 'scrollback');
|
|
326
|
+
let restored = 0;
|
|
327
|
+
for (const s of pending.sessions) {
|
|
328
|
+
// Load scrollback from disk
|
|
329
|
+
let initialScrollback;
|
|
330
|
+
const scrollbackPath = path.join(scrollbackDirPath, s.id + '.buf');
|
|
331
|
+
try {
|
|
332
|
+
const data = fs.readFileSync(scrollbackPath, 'utf-8');
|
|
333
|
+
if (data.length > 0)
|
|
334
|
+
initialScrollback = [data];
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Missing scrollback is non-fatal
|
|
338
|
+
}
|
|
339
|
+
// Determine spawn command and args
|
|
340
|
+
let command;
|
|
341
|
+
let args = [];
|
|
342
|
+
if (s.customCommand) {
|
|
343
|
+
// Terminal session — respawn the shell
|
|
344
|
+
command = s.customCommand;
|
|
345
|
+
}
|
|
346
|
+
else if (s.useTmux && s.tmuxSessionName) {
|
|
347
|
+
// Tmux session — check if tmux session is still alive
|
|
348
|
+
let tmuxAlive = false;
|
|
349
|
+
try {
|
|
350
|
+
await execFileAsync('tmux', ['has-session', '-t', s.tmuxSessionName]);
|
|
351
|
+
tmuxAlive = true;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// tmux session is gone
|
|
355
|
+
}
|
|
356
|
+
if (tmuxAlive) {
|
|
357
|
+
// Attach to surviving tmux session
|
|
358
|
+
command = 'tmux';
|
|
359
|
+
args = ['attach-session', '-t', s.tmuxSessionName];
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
// Tmux session died — fall back to agent with continue args
|
|
363
|
+
args = [...AGENT_CONTINUE_ARGS[s.agent]];
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// Non-tmux agent session — respawn with continue args
|
|
368
|
+
args = [...AGENT_CONTINUE_ARGS[s.agent]];
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const createParams = {
|
|
372
|
+
id: s.id,
|
|
373
|
+
type: s.type,
|
|
374
|
+
agent: s.agent,
|
|
375
|
+
repoName: s.repoName,
|
|
376
|
+
repoPath: s.repoPath,
|
|
377
|
+
cwd: s.cwd,
|
|
378
|
+
root: s.root,
|
|
379
|
+
worktreeName: s.worktreeName,
|
|
380
|
+
branchName: s.branchName,
|
|
381
|
+
displayName: s.displayName,
|
|
382
|
+
args,
|
|
383
|
+
useTmux: false, // Don't re-wrap in tmux — either attaching to existing or using plain agent
|
|
384
|
+
};
|
|
385
|
+
if (command)
|
|
386
|
+
createParams.command = command;
|
|
387
|
+
if (initialScrollback)
|
|
388
|
+
createParams.initialScrollback = initialScrollback;
|
|
389
|
+
create(createParams);
|
|
390
|
+
restored++;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
console.error(`Failed to restore session ${s.id} (${s.displayName})`);
|
|
394
|
+
}
|
|
395
|
+
// Clean up scrollback file
|
|
396
|
+
try {
|
|
397
|
+
fs.unlinkSync(scrollbackPath);
|
|
398
|
+
}
|
|
399
|
+
catch { /* ignore */ }
|
|
400
|
+
}
|
|
401
|
+
// Clean up
|
|
402
|
+
try {
|
|
403
|
+
fs.unlinkSync(pendingPath);
|
|
404
|
+
}
|
|
405
|
+
catch { /* ignore */ }
|
|
406
|
+
try {
|
|
407
|
+
fs.rmdirSync(path.join(configDir, 'scrollback'));
|
|
408
|
+
}
|
|
409
|
+
catch { /* ignore — may not be empty */ }
|
|
410
|
+
return restored;
|
|
411
|
+
}
|
|
412
|
+
/** Returns the set of tmux session names currently owned by restored sessions */
|
|
413
|
+
function activeTmuxSessionNames() {
|
|
414
|
+
const names = new Set();
|
|
415
|
+
for (const session of sessions.values()) {
|
|
416
|
+
if (session.tmuxSessionName)
|
|
417
|
+
names.add(session.tmuxSessionName);
|
|
418
|
+
}
|
|
419
|
+
return names;
|
|
420
|
+
}
|
|
421
|
+
export { create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, findRepoSession, nextTerminalName, serializeAll, restoreFromDisk, activeTmuxSessionNames, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, resolveTmuxSpawn, generateTmuxSessionName };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { processIntent } from '../server/mobile-input-pipeline.js';
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const FIXTURES_DIR = join(__dirname, '..', '..', 'test', 'fixtures', 'mobile-input');
|
|
9
|
+
function loadFixture(filename) {
|
|
10
|
+
const raw = readFileSync(join(FIXTURES_DIR, filename), 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
function replayFixture(fixture) {
|
|
14
|
+
let totalPayload = '';
|
|
15
|
+
for (const step of fixture.events) {
|
|
16
|
+
const intent = {
|
|
17
|
+
type: step.inputType,
|
|
18
|
+
data: step.data,
|
|
19
|
+
rangeStart: step.rangeStart,
|
|
20
|
+
rangeEnd: step.rangeEnd,
|
|
21
|
+
valueBefore: step.valueBefore,
|
|
22
|
+
cursorBefore: step.cursorBefore,
|
|
23
|
+
};
|
|
24
|
+
const result = processIntent(intent, step.valueAfter);
|
|
25
|
+
totalPayload += result.payload;
|
|
26
|
+
}
|
|
27
|
+
return totalPayload;
|
|
28
|
+
}
|
|
29
|
+
function assertReplacementNotLost(payload, expectedReplacement, fixtureName) {
|
|
30
|
+
assert.ok(payload.includes(expectedReplacement), `[${fixtureName}] Payload contains only backspaces — replacement text "${expectedReplacement}" was lost. Got: ${JSON.stringify(payload)}`);
|
|
31
|
+
}
|
|
32
|
+
// ── Fixture replay tests ─────────────────────────────────────────────
|
|
33
|
+
describe('mobile-input-pipeline: fixture replay', () => {
|
|
34
|
+
const fixtureFiles = readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json'));
|
|
35
|
+
for (const file of fixtureFiles) {
|
|
36
|
+
const fixture = loadFixture(file);
|
|
37
|
+
it(`${fixture.name}: ${fixture.description}`, () => {
|
|
38
|
+
const actualPayload = replayFixture(fixture);
|
|
39
|
+
assert.strictEqual(actualPayload, fixture.expectedPayload, `Payload mismatch for fixture "${fixture.name}". ` +
|
|
40
|
+
`Expected: ${JSON.stringify(fixture.expectedPayload)}, ` +
|
|
41
|
+
`Got: ${JSON.stringify(actualPayload)}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// ── Autocorrect invariant tests ──────────────────────────────────────
|
|
46
|
+
describe('mobile-input-pipeline: autocorrect always includes replacement text', () => {
|
|
47
|
+
it('Gboard range replacement includes replacement text', () => {
|
|
48
|
+
const fixture = loadFixture('gboard-autocorrect-range.json');
|
|
49
|
+
const payload = replayFixture(fixture);
|
|
50
|
+
assertReplacementNotLost(payload, 'the', fixture.name);
|
|
51
|
+
});
|
|
52
|
+
it('Gboard cursor-0 recovery includes replacement text', () => {
|
|
53
|
+
const fixture = loadFixture('gboard-autocorrect-cursor0.json');
|
|
54
|
+
const payload = replayFixture(fixture);
|
|
55
|
+
assertReplacementNotLost(payload, 'the', fixture.name);
|
|
56
|
+
});
|
|
57
|
+
it('iOS insertReplacementText includes replacement text', () => {
|
|
58
|
+
const fixture = loadFixture('ios-replacement-text.json');
|
|
59
|
+
const payload = replayFixture(fixture);
|
|
60
|
+
assertReplacementNotLost(payload, 'the', fixture.name);
|
|
61
|
+
});
|
|
62
|
+
it('multi-word buffer: only target word deleted, replacement inserted', () => {
|
|
63
|
+
const fixture = loadFixture('gboard-autocorrect-multi-word.json');
|
|
64
|
+
const payload = replayFixture(fixture);
|
|
65
|
+
assertReplacementNotLost(payload, 'the', fixture.name);
|
|
66
|
+
const backspaceCount = (payload.match(/\x7f/g) ?? []).length;
|
|
67
|
+
assert.strictEqual(backspaceCount, 3, `Expected 3 backspaces (for "teh") but got ${backspaceCount} — ` +
|
|
68
|
+
`pipeline is deleting more than the target word`);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ── processIntent unit tests ─────────────────────────────────────────
|
|
72
|
+
describe('mobile-input-pipeline: processIntent', () => {
|
|
73
|
+
it('normal character insertion sends data directly', () => {
|
|
74
|
+
const result = processIntent({
|
|
75
|
+
type: 'insertText', data: 'a',
|
|
76
|
+
rangeStart: 5, rangeEnd: 5,
|
|
77
|
+
valueBefore: 'hello', cursorBefore: 5,
|
|
78
|
+
}, 'helloa');
|
|
79
|
+
assert.strictEqual(result.payload, 'a');
|
|
80
|
+
assert.strictEqual(result.newInputValue, undefined);
|
|
81
|
+
});
|
|
82
|
+
it('autocorrect with range sends backspaces + replacement', () => {
|
|
83
|
+
const result = processIntent({
|
|
84
|
+
type: 'insertText', data: 'the',
|
|
85
|
+
rangeStart: 0, rangeEnd: 3,
|
|
86
|
+
valueBefore: 'teh', cursorBefore: 3,
|
|
87
|
+
}, 'the');
|
|
88
|
+
assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
|
|
89
|
+
});
|
|
90
|
+
it('cursor-0 recovery sets newInputValue', () => {
|
|
91
|
+
const result = processIntent({
|
|
92
|
+
type: 'insertText', data: 'the',
|
|
93
|
+
rangeStart: null, rangeEnd: null,
|
|
94
|
+
valueBefore: 'teh', cursorBefore: 0,
|
|
95
|
+
}, 'theteh');
|
|
96
|
+
assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
|
|
97
|
+
assert.strictEqual(result.newInputValue, 'the');
|
|
98
|
+
});
|
|
99
|
+
it('deleteContentBackward with range', () => {
|
|
100
|
+
const result = processIntent({
|
|
101
|
+
type: 'deleteContentBackward', data: null,
|
|
102
|
+
rangeStart: 4, rangeEnd: 5,
|
|
103
|
+
valueBefore: 'hello', cursorBefore: 5,
|
|
104
|
+
}, 'hell');
|
|
105
|
+
assert.strictEqual(result.payload, '\x7f');
|
|
106
|
+
});
|
|
107
|
+
it('deleteWordBackward with range sends correct backspace count', () => {
|
|
108
|
+
const result = processIntent({
|
|
109
|
+
type: 'deleteWordBackward', data: null,
|
|
110
|
+
rangeStart: 6, rangeEnd: 11,
|
|
111
|
+
valueBefore: 'hello world', cursorBefore: 11,
|
|
112
|
+
}, 'hello ');
|
|
113
|
+
assert.strictEqual(result.payload, '\x7f\x7f\x7f\x7f\x7f');
|
|
114
|
+
});
|
|
115
|
+
it('deleteContentBackward without range falls back to diff', () => {
|
|
116
|
+
const result = processIntent({
|
|
117
|
+
type: 'deleteContentBackward', data: null,
|
|
118
|
+
rangeStart: null, rangeEnd: null,
|
|
119
|
+
valueBefore: 'hello', cursorBefore: 5,
|
|
120
|
+
}, 'hell');
|
|
121
|
+
assert.strictEqual(result.payload, '\x7f');
|
|
122
|
+
});
|
|
123
|
+
it('insertReplacementText sends backspaces + replacement', () => {
|
|
124
|
+
const result = processIntent({
|
|
125
|
+
type: 'insertReplacementText', data: 'the',
|
|
126
|
+
rangeStart: 0, rangeEnd: 3,
|
|
127
|
+
valueBefore: 'teh', cursorBefore: 3,
|
|
128
|
+
}, 'the');
|
|
129
|
+
assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
|
|
130
|
+
});
|
|
131
|
+
it('insertFromPaste uses diff to extract pasted text', () => {
|
|
132
|
+
const result = processIntent({
|
|
133
|
+
type: 'insertFromPaste', data: null,
|
|
134
|
+
rangeStart: null, rangeEnd: null,
|
|
135
|
+
valueBefore: 'hello', cursorBefore: 5,
|
|
136
|
+
}, 'hello world');
|
|
137
|
+
assert.strictEqual(result.payload, ' world');
|
|
138
|
+
});
|
|
139
|
+
it('unknown inputType falls back to diff', () => {
|
|
140
|
+
const result = processIntent({
|
|
141
|
+
type: 'insertFromYank', data: null,
|
|
142
|
+
rangeStart: null, rangeEnd: null,
|
|
143
|
+
valueBefore: 'hllo', cursorBefore: 1,
|
|
144
|
+
}, 'hello');
|
|
145
|
+
assert.strictEqual(result.payload, '\x7f\x7f\x7fello');
|
|
146
|
+
});
|
|
147
|
+
it('empty payload for no-op diff', () => {
|
|
148
|
+
const result = processIntent({
|
|
149
|
+
type: 'insertText', data: null,
|
|
150
|
+
rangeStart: null, rangeEnd: null,
|
|
151
|
+
valueBefore: 'hello', cursorBefore: 5,
|
|
152
|
+
}, 'hello');
|
|
153
|
+
assert.strictEqual(result.payload, '');
|
|
154
|
+
});
|
|
155
|
+
it('handles emoji codepoints correctly in autocorrect range', () => {
|
|
156
|
+
const result = processIntent({
|
|
157
|
+
type: 'insertText', data: 'smile',
|
|
158
|
+
rangeStart: 0, rangeEnd: 2,
|
|
159
|
+
valueBefore: '😊', cursorBefore: 2,
|
|
160
|
+
}, 'smile');
|
|
161
|
+
assert.strictEqual(result.payload, '\x7fsmile');
|
|
162
|
+
});
|
|
163
|
+
});
|