handmux 0.27.2 → 0.29.0
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/bin/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- package/dist/public/assets/index-Cdrh2CUp.css +0 -32
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isSessionUuid } from './scanUtils.js';
|
|
4
|
+
import { claudeLocalCommandCompletion } from './claudeLocalCommand.js';
|
|
5
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
6
|
+
const METADATA = new Set(['last-prompt', 'ai-title', 'mode', 'permission-mode', 'file-history-snapshot']);
|
|
7
|
+
const MAX_BYTES = 65536;
|
|
8
|
+
const MAX_SESSIONS = 128;
|
|
9
|
+
function interruption(rows, sessionId, promptId, after, now) {
|
|
10
|
+
let duration;
|
|
11
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
12
|
+
const row = rows[index];
|
|
13
|
+
if (!row)
|
|
14
|
+
return null;
|
|
15
|
+
if (typeof row.type === 'string' && METADATA.has(row.type))
|
|
16
|
+
continue;
|
|
17
|
+
if (row.sessionId !== sessionId || row.isSidechain !== false || !isSessionUuid(row.uuid)
|
|
18
|
+
|| row.isMeta === true || row.isCompactSummary === true)
|
|
19
|
+
return null;
|
|
20
|
+
const timestamp = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : NaN;
|
|
21
|
+
if (!Number.isFinite(timestamp) || timestamp <= after || timestamp > now)
|
|
22
|
+
return null;
|
|
23
|
+
if (row.type === 'system' && row.subtype === 'turn_duration' && !duration) {
|
|
24
|
+
duration = row;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (row.type !== 'user' || (promptId !== undefined && promptId !== row.promptId)
|
|
28
|
+
|| ['promptSource', 'origin', 'permissionMode'].some((key) => Object.hasOwn(row, key)))
|
|
29
|
+
return null;
|
|
30
|
+
const message = record(row.message);
|
|
31
|
+
const content = message?.content;
|
|
32
|
+
if (message?.role !== 'user' || !Array.isArray(content) || content.length !== 1)
|
|
33
|
+
return null;
|
|
34
|
+
const block = record(content[0]);
|
|
35
|
+
if (block?.type !== 'text')
|
|
36
|
+
return null;
|
|
37
|
+
if (block.text !== '[Request interrupted by user for tool use]'
|
|
38
|
+
&& !(block.text === '[Request interrupted by user]' && typeof row.interruptedMessageId === 'string'
|
|
39
|
+
&& row.interruptedMessageId.length > 0 && row.interruptedMessageId.length <= 256))
|
|
40
|
+
return null;
|
|
41
|
+
if (duration && (duration.parentUuid !== row.uuid || Date.parse(String(duration.timestamp)) < timestamp))
|
|
42
|
+
return null;
|
|
43
|
+
return `claude-interrupted:${sessionId}:${row.uuid}`;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
// Shared by the Claude activity reader and its Inbox Connector. No timer: existing polls stat first,
|
|
48
|
+
// and one changed file produces at most one 64 KiB read and JSON parse across both consumers.
|
|
49
|
+
export class ClaudeNativeTailReader {
|
|
50
|
+
#tails = new Map();
|
|
51
|
+
#nativeProcesses = new Set();
|
|
52
|
+
#statuses = new Map();
|
|
53
|
+
read(payload, after, now, process, src) {
|
|
54
|
+
const native = this.#readTranscript(payload, after, now);
|
|
55
|
+
const file = payload.transcript_path;
|
|
56
|
+
if (!process || !Number.isSafeInteger(process.pid) || process.pid <= 0
|
|
57
|
+
|| !Number.isFinite(process.startedAt) || typeof file !== 'string' || !path.isAbsolute(file)
|
|
58
|
+
|| !isSessionUuid(payload.session_id) || path.basename(file) !== `${payload.session_id}.jsonl`
|
|
59
|
+
|| path.basename(path.dirname(path.dirname(file))) !== 'projects')
|
|
60
|
+
return native;
|
|
61
|
+
const registry = path.join(path.dirname(path.dirname(path.dirname(file))), 'sessions', `${process.pid}.json`);
|
|
62
|
+
const identity = `${payload.session_id}:${process.pid}:${process.startedAt}`;
|
|
63
|
+
const unknown = () => this.#nativeProcesses.has(identity) ? { ...native, status: 'unknown', settled: null, localCommand: null } : native;
|
|
64
|
+
let row;
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.statSync(registry);
|
|
67
|
+
this.#nativeProcesses.add(identity);
|
|
68
|
+
while (this.#nativeProcesses.size > MAX_SESSIONS)
|
|
69
|
+
this.#nativeProcesses.delete(this.#nativeProcesses.values().next().value);
|
|
70
|
+
if (!stat.isFile() || stat.size > 16384)
|
|
71
|
+
return unknown();
|
|
72
|
+
const fingerprint = [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
|
|
73
|
+
const cached = this.#statuses.get(registry);
|
|
74
|
+
if (cached?.fingerprint === fingerprint)
|
|
75
|
+
row = cached.row;
|
|
76
|
+
else {
|
|
77
|
+
const fd = fs.openSync(registry, 'r');
|
|
78
|
+
try {
|
|
79
|
+
const buffer = Buffer.alloc(stat.size);
|
|
80
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
81
|
+
const current = fs.fstatSync(fd);
|
|
82
|
+
if (read !== buffer.length
|
|
83
|
+
|| [current.dev, current.ino, current.size, current.mtimeMs, current.ctimeMs].join(':') !== fingerprint)
|
|
84
|
+
return unknown();
|
|
85
|
+
row = record(JSON.parse(buffer.toString('utf8')));
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
this.#statuses.delete(registry);
|
|
91
|
+
this.#statuses.set(registry, { fingerprint, row });
|
|
92
|
+
while (this.#statuses.size > MAX_SESSIONS)
|
|
93
|
+
this.#statuses.delete(this.#statuses.keys().next().value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.#statuses.delete(registry);
|
|
98
|
+
return unknown();
|
|
99
|
+
}
|
|
100
|
+
if (!row || row.pid !== process.pid || row.sessionId !== payload.session_id || row.kind !== 'interactive'
|
|
101
|
+
|| typeof row.procStart !== 'string' || Date.parse(`${row.procStart} UTC`) !== process.startedAt)
|
|
102
|
+
return unknown();
|
|
103
|
+
this.#nativeProcesses.add(identity);
|
|
104
|
+
while (this.#nativeProcesses.size > MAX_SESSIONS)
|
|
105
|
+
this.#nativeProcesses.delete(this.#nativeProcesses.values().next().value);
|
|
106
|
+
const hookSettled = src === 'stop' || src === 'stopfail'
|
|
107
|
+
|| (src === 'start' && ['startup', 'clear', 'resume'].includes(String(payload.source)))
|
|
108
|
+
|| (src === 'compact' && payload.trigger === 'manual');
|
|
109
|
+
// An explicit terminal Hook owns that completion identity, even if its native idle effect lands
|
|
110
|
+
// later. This also handles /clear, which may never transition out of native idle at all.
|
|
111
|
+
if (hookSettled && typeof row.statusUpdatedAt === 'number'
|
|
112
|
+
&& (row.statusUpdatedAt <= after || row.status === 'idle'))
|
|
113
|
+
return native;
|
|
114
|
+
if (typeof row.statusUpdatedAt === 'number' && row.statusUpdatedAt <= after) {
|
|
115
|
+
return { ...native, settled: null, localCommand: null };
|
|
116
|
+
}
|
|
117
|
+
if (typeof row.statusUpdatedAt !== 'number' || row.statusUpdatedAt < process.startedAt || row.statusUpdatedAt > now
|
|
118
|
+
|| (row.status === 'idle' && (!native.transcriptComplete || row.statusUpdatedAt <= after || row.statusUpdatedAt < (native.latestNativeAt ?? 0)))
|
|
119
|
+
|| !['idle', 'busy', 'waiting'].includes(String(row.status)))
|
|
120
|
+
return unknown();
|
|
121
|
+
const status = row.status;
|
|
122
|
+
const statusEventId = `claude-native:${payload.session_id}:${process.pid}:${process.startedAt}:${row.statusUpdatedAt}:${status}`;
|
|
123
|
+
if (status !== 'idle')
|
|
124
|
+
return { ...native, status, statusEventId, settled: null, localCommand: null };
|
|
125
|
+
// statusUpdatedAt changes only on native activity transitions, unlike metadata updatedAt.
|
|
126
|
+
return { ...native, status, settled: `claude-native-idle:${payload.session_id}:${process.pid}:${process.startedAt}:${row.statusUpdatedAt}` };
|
|
127
|
+
}
|
|
128
|
+
#readTranscript(payload, after, now) {
|
|
129
|
+
const sessionId = payload.session_id;
|
|
130
|
+
const file = payload.transcript_path;
|
|
131
|
+
const absent = { interruption: null, localCommand: undefined };
|
|
132
|
+
if (!isSessionUuid(sessionId) || typeof file !== 'string' || path.basename(file) !== `${sessionId}.jsonl`)
|
|
133
|
+
return absent;
|
|
134
|
+
let tail;
|
|
135
|
+
try {
|
|
136
|
+
const stat = fs.statSync(file);
|
|
137
|
+
if (!stat.isFile())
|
|
138
|
+
return { interruption: null, localCommand: null };
|
|
139
|
+
const fingerprint = [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
|
|
140
|
+
const cached = this.#tails.get(file);
|
|
141
|
+
if (cached?.fingerprint === fingerprint)
|
|
142
|
+
tail = cached;
|
|
143
|
+
else {
|
|
144
|
+
const fd = fs.openSync(file, 'r');
|
|
145
|
+
let text;
|
|
146
|
+
try {
|
|
147
|
+
const current = fs.fstatSync(fd);
|
|
148
|
+
if ([current.dev, current.ino, current.size, current.mtimeMs, current.ctimeMs].join(':') !== fingerprint) {
|
|
149
|
+
this.#invalidate(file, after);
|
|
150
|
+
return { interruption: null, localCommand: null };
|
|
151
|
+
}
|
|
152
|
+
const length = Math.min(stat.size, MAX_BYTES);
|
|
153
|
+
const buffer = Buffer.alloc(length);
|
|
154
|
+
const read = fs.readSync(fd, buffer, 0, length, stat.size - length);
|
|
155
|
+
const finished = fs.fstatSync(fd);
|
|
156
|
+
if (read !== length
|
|
157
|
+
|| [finished.dev, finished.ino, finished.size, finished.mtimeMs, finished.ctimeMs].join(':') !== fingerprint) {
|
|
158
|
+
this.#invalidate(file, after);
|
|
159
|
+
return { interruption: null, localCommand: null };
|
|
160
|
+
}
|
|
161
|
+
text = buffer.subarray(0, read).toString('utf8');
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
fs.closeSync(fd);
|
|
165
|
+
}
|
|
166
|
+
const lines = text.split('\n');
|
|
167
|
+
if (stat.size > MAX_BYTES)
|
|
168
|
+
lines.shift();
|
|
169
|
+
const rows = lines.filter((line) => line.trim()).map((line) => {
|
|
170
|
+
try {
|
|
171
|
+
return record(JSON.parse(line));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const invalidatedThrough = cached && (cached.fingerprint.split(':')[1] !== String(stat.ino)
|
|
178
|
+
|| cached.fingerprint.split(':')[0] !== String(stat.dev)
|
|
179
|
+
|| Number(cached.fingerprint.split(':')[2]) > stat.size)
|
|
180
|
+
? Math.max(cached.lastHookTime, cached.invalidatedThrough ?? 0) : cached?.invalidatedThrough;
|
|
181
|
+
tail = { sessionId, fingerprint, rows, complete: !text || text.endsWith('\n'), lastHookTime: Math.max(cached?.lastHookTime ?? 0, after),
|
|
182
|
+
...(invalidatedThrough === undefined ? {} : { invalidatedThrough }) };
|
|
183
|
+
}
|
|
184
|
+
this.#tails.delete(file);
|
|
185
|
+
this.#tails.set(file, tail);
|
|
186
|
+
while (this.#tails.size > MAX_SESSIONS)
|
|
187
|
+
this.#tails.delete(this.#tails.keys().next().value);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
this.#invalidate(file, after);
|
|
191
|
+
return { interruption: null, localCommand: null };
|
|
192
|
+
}
|
|
193
|
+
if (!tail.complete)
|
|
194
|
+
return { interruption: null, localCommand: null };
|
|
195
|
+
tail.lastHookTime = Math.max(tail.lastHookTime, after);
|
|
196
|
+
const interrupted = interruption(tail.rows, sessionId, payload.prompt_id, after, now);
|
|
197
|
+
const localCommand = claudeLocalCommandCompletion(payload, after, now, tail.rows);
|
|
198
|
+
// A local picker dispatched after an interrupt fires no new Hook either. Only inherit the idle
|
|
199
|
+
// lifecycle when that exact command/output pair immediately follows a proven interruption.
|
|
200
|
+
let settled = interrupted;
|
|
201
|
+
if (!settled && localCommand) {
|
|
202
|
+
let native = tail.rows.filter((row) => !row || typeof row.type !== 'string' || !METADATA.has(row.type));
|
|
203
|
+
while (native.length >= 3 && claudeLocalCommandCompletion(payload, after, now, native)) {
|
|
204
|
+
const commandTime = Date.parse(String(native.at(-2)?.timestamp));
|
|
205
|
+
native = native.slice(0, -2);
|
|
206
|
+
if (Date.parse(String(native.at(-1)?.timestamp)) > commandTime)
|
|
207
|
+
break;
|
|
208
|
+
if (interruption(native, sessionId, payload.prompt_id, after, now)) {
|
|
209
|
+
settled = localCommand;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
interruption: interrupted,
|
|
216
|
+
settled,
|
|
217
|
+
localCommand: localCommand === undefined && tail.invalidatedThrough !== undefined && after <= tail.invalidatedThrough
|
|
218
|
+
? null : localCommand,
|
|
219
|
+
lastRecord: tail.rows.at(-1) ?? null,
|
|
220
|
+
transcriptComplete: true,
|
|
221
|
+
latestNativeAt: Math.max(0, ...tail.rows.map((row) => typeof row?.timestamp === 'string' ? Date.parse(row.timestamp) || 0 : 0)),
|
|
222
|
+
...this.#restoredPrompt(tail.rows, payload),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
#restoredPrompt(rows, payload) {
|
|
226
|
+
// Match native auto_restore_cancel: thinking alone is not visible output. Require the exact
|
|
227
|
+
// Hook prompt and its main-chain user record; malformed or unknown rows cannot authorize deletion.
|
|
228
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
229
|
+
const row = rows[index];
|
|
230
|
+
if (!row)
|
|
231
|
+
return {};
|
|
232
|
+
if (typeof row.type === 'string' && METADATA.has(row.type))
|
|
233
|
+
continue;
|
|
234
|
+
if (row.sessionId !== payload.session_id || row.isSidechain !== false || row.isMeta === true)
|
|
235
|
+
return {};
|
|
236
|
+
const message = record(row.message);
|
|
237
|
+
if (row.type === 'assistant' && Array.isArray(message?.content)
|
|
238
|
+
&& message.content.every((block) => record(block)?.type === 'thinking'))
|
|
239
|
+
continue;
|
|
240
|
+
if (row.type !== 'user' || row.promptId !== payload.prompt_id || row.promptSource !== 'typed'
|
|
241
|
+
|| message?.role !== 'user' || typeof payload.prompt !== 'string' || !payload.prompt)
|
|
242
|
+
return {};
|
|
243
|
+
const content = message.content;
|
|
244
|
+
const text = typeof content === 'string' ? content
|
|
245
|
+
: Array.isArray(content) && content.length === 1 && record(content[0])?.type === 'text'
|
|
246
|
+
? record(content[0])?.text : null;
|
|
247
|
+
return text === payload.prompt ? { restoredPrompt: payload.prompt } : {};
|
|
248
|
+
}
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
retain(sessionIds) {
|
|
252
|
+
for (const [file, tail] of this.#tails)
|
|
253
|
+
if (!sessionIds.has(tail.sessionId))
|
|
254
|
+
this.#tails.delete(file);
|
|
255
|
+
}
|
|
256
|
+
#invalidate(file, after) {
|
|
257
|
+
const previous = this.#tails.get(file);
|
|
258
|
+
if (!previous)
|
|
259
|
+
return;
|
|
260
|
+
// Keep only the invalidation boundary across a rotation's temporary ENOENT, never old evidence.
|
|
261
|
+
this.#tails.set(file, { ...previous, fingerprint: '', rows: [], complete: false,
|
|
262
|
+
lastHookTime: Math.max(previous.lastHookTime, after),
|
|
263
|
+
invalidatedThrough: Math.max(previous.lastHookTime, previous.invalidatedThrough ?? 0, after) });
|
|
264
|
+
}
|
|
265
|
+
release(sessionId) {
|
|
266
|
+
for (const [file, tail] of this.#tails)
|
|
267
|
+
if (tail.sessionId === sessionId)
|
|
268
|
+
this.#tails.delete(file);
|
|
269
|
+
}
|
|
270
|
+
clear() { this.#tails.clear(); this.#statuses.clear(); this.#nativeProcesses.clear(); }
|
|
271
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { serializePaneInput } from '../paneInput.js';
|
|
2
|
+
const SUBMIT_GAP_MS = 120;
|
|
3
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
// Read the entire one-line native editor, bounded by its two rules. Wrapped input, attachments,
|
|
5
|
+
// selection menus and unknown layouts fail closed. End is sent first: placeholder text is painted
|
|
6
|
+
// after the cursor but isn't part of the editor; actual text places the cursor at its end.
|
|
7
|
+
export function claudeSingleLineDraft(screen, cursor) {
|
|
8
|
+
const lines = screen.split('\n');
|
|
9
|
+
const line = lines[cursor.cursorY];
|
|
10
|
+
if (!line || !/^─{3,}$/.test(lines[cursor.cursorY - 1]?.trim() ?? '')
|
|
11
|
+
|| !/^─{3,}$/.test(lines[cursor.cursorY + 1]?.trim() ?? '')
|
|
12
|
+
|| !/^❯[ \u00a0]/.test(line))
|
|
13
|
+
return null;
|
|
14
|
+
const value = line.slice(2).trimEnd();
|
|
15
|
+
if (cursor.cursorX === 2)
|
|
16
|
+
return ''; // Native suggestions are painted here but End does not enter them.
|
|
17
|
+
if (!value || /[\x00-\x1f\x7f]/.test(value) || cursor.cursorX <= 2)
|
|
18
|
+
return null;
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function sendClaudePanePrompt(commands, paneId, text, restoredPrompt, guard) {
|
|
22
|
+
return serializePaneInput(paneId, async () => {
|
|
23
|
+
if (guard && !await guard.validate())
|
|
24
|
+
return { nativeMutation: false };
|
|
25
|
+
await commands.exitCopyModeIfActive(paneId);
|
|
26
|
+
await commands.sendKey(paneId, 'End');
|
|
27
|
+
await delay(SUBMIT_GAP_MS);
|
|
28
|
+
const readDraft = async () => claudeSingleLineDraft(await commands.capturePlain(paneId), await commands.paneInfo(paneId));
|
|
29
|
+
const draft = await readDraft();
|
|
30
|
+
if (draft === null || (draft !== '' && draft !== restoredPrompt())) {
|
|
31
|
+
return { nativeMutation: false, reason: 'terminal_draft_conflict' };
|
|
32
|
+
}
|
|
33
|
+
if (guard && !await guard.validate())
|
|
34
|
+
return { nativeMutation: false };
|
|
35
|
+
if (draft) {
|
|
36
|
+
await commands.sendKey(paneId, 'C-u');
|
|
37
|
+
await delay(SUBMIT_GAP_MS);
|
|
38
|
+
if (await readDraft() !== '')
|
|
39
|
+
return { nativeMutation: false, reason: 'terminal_draft_conflict' };
|
|
40
|
+
}
|
|
41
|
+
await commands.sendText(paneId, text);
|
|
42
|
+
if (text)
|
|
43
|
+
await delay(SUBMIT_GAP_MS);
|
|
44
|
+
await commands.sendEnter(paneId);
|
|
45
|
+
return { nativeMutation: true };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
2
4
|
import { stat } from 'node:fs/promises';
|
|
3
5
|
import { transcriptReader } from '../transcriptReader.js';
|
|
4
6
|
import { createCodexTranscriptParser, } from '../codexTranscriptParse.js';
|
|
@@ -6,6 +8,35 @@ import { projectCodexConversationMutation } from '../codexConversationProjection
|
|
|
6
8
|
import { parseCodexStreamEvent } from '../codexStreamProtocol.js';
|
|
7
9
|
import { resolveCodexRollout, sessionsDir } from './codex.js';
|
|
8
10
|
import { clipConversationText, safeProviderDiffPath, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
|
|
11
|
+
// Display labels deliberately erase the home-directory identity. Downloads must use the
|
|
12
|
+
// original tool input instead; the existing file API still checks realpath and allowed roots.
|
|
13
|
+
function toolImagePath(input) {
|
|
14
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
15
|
+
return null;
|
|
16
|
+
const value = input;
|
|
17
|
+
const path = value.path;
|
|
18
|
+
if (typeof path !== 'string' || !path || path.includes('\0'))
|
|
19
|
+
return null;
|
|
20
|
+
const absolute = (raw) => {
|
|
21
|
+
if (typeof raw !== 'string' || raw.includes('\0'))
|
|
22
|
+
return null;
|
|
23
|
+
if (raw.startsWith('/'))
|
|
24
|
+
return raw;
|
|
25
|
+
if (raw === '~')
|
|
26
|
+
return homedir();
|
|
27
|
+
if (raw.startsWith('~/'))
|
|
28
|
+
return resolve(homedir(), raw.slice(2));
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
const direct = absolute(path);
|
|
32
|
+
if (direct)
|
|
33
|
+
return direct;
|
|
34
|
+
// Other users' shell abbreviations, URLs and Windows paths are not POSIX relative paths.
|
|
35
|
+
if (path.startsWith('~') || /^[a-zA-Z][\w+.-]*:/.test(path))
|
|
36
|
+
return null;
|
|
37
|
+
const cwd = absolute(value.cwd);
|
|
38
|
+
return cwd ? resolve(cwd, path) : null;
|
|
39
|
+
}
|
|
9
40
|
const DURABLE_QUIET_MS = 100;
|
|
10
41
|
const DEFAULT_DURABLE_SETTLE_TIMEOUT_MS = 15_000;
|
|
11
42
|
const MAX_OPENING_NATIVE_EVENTS = 4_096;
|
|
@@ -328,6 +359,10 @@ function projectDurableMessage(message, sessionId, suppressedCompactorItems) {
|
|
|
328
359
|
} : {}),
|
|
329
360
|
}];
|
|
330
361
|
}
|
|
362
|
+
if (message.type === 'notice' && typeof message.text === 'string') {
|
|
363
|
+
return [{ ...itemBase(message, sessionId), kind: 'notice', level: message.noticeLevel || 'info',
|
|
364
|
+
code: 'provider_error', message: clipConversationText(message.text).text }];
|
|
365
|
+
}
|
|
331
366
|
if (message.type === 'thinking' && typeof message.text === 'string') {
|
|
332
367
|
// Codex rollout stores the provider's public reasoning summary, not hidden reasoning content.
|
|
333
368
|
const output = clipConversationText(message.text);
|
|
@@ -364,6 +399,8 @@ function projectDurableMessage(message, sessionId, suppressedCompactorItems) {
|
|
|
364
399
|
extensions: {
|
|
365
400
|
'conversation.tool': {
|
|
366
401
|
name, input: extensionInput, result: result?.text ?? null,
|
|
402
|
+
...((name === 'view_image' || name === 'functions.view_image')
|
|
403
|
+
? { imagePath: toolImagePath(message.tool.input) } : {}),
|
|
367
404
|
isError: message.tool.isError === true,
|
|
368
405
|
...(outcome === undefined ? {} : { outcome }),
|
|
369
406
|
...(safeDiff === undefined ? {} : { diff: safeDiff }),
|
|
@@ -926,6 +963,15 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
|
|
|
926
963
|
}
|
|
927
964
|
}
|
|
928
965
|
if (event.type === 'cursorReset' || event.type === 'disconnected' || event.type === 'error') {
|
|
966
|
+
if (event.type === 'error' && event.message.trim()) {
|
|
967
|
+
const provisionalId = `codex-stream-error:${sessionId}:${live.sourceSequence + 1}`;
|
|
968
|
+
const draft = {
|
|
969
|
+
kind: 'notice', level: 'warning', code: 'provider_error', message: event.message.trim(),
|
|
970
|
+
};
|
|
971
|
+
await emit({ type: 'item.opened', provisionalId, draft });
|
|
972
|
+
await emit({ type: 'item.settled', provisionalId,
|
|
973
|
+
item: { ...draft, id: provisionalId, sessionId, status: 'complete' } });
|
|
974
|
+
}
|
|
929
975
|
await emit({ type: 'stream.gap', afterSourceSequence: live.sourceSequence });
|
|
930
976
|
return;
|
|
931
977
|
}
|
|
@@ -61,7 +61,9 @@ function parseSnapshot(value, availability, run, sourceId, sequence, label = 'Pi
|
|
|
61
61
|
}
|
|
62
62
|
if (!isRecord(value) || typeof value.availability !== 'string'
|
|
63
63
|
|| !AVAILABILITIES.has(value.availability)
|
|
64
|
-
|| !optionalText(value.message, 1024)
|
|
64
|
+
|| !optionalText(value.message, 1024)
|
|
65
|
+
|| (value.sourceSequence !== undefined && (!Number.isSafeInteger(value.sourceSequence)
|
|
66
|
+
|| Number(value.sourceSequence) < 1))) {
|
|
65
67
|
throw new PiInboxBridgeError(`${label} Inbox Bridge returned an invalid snapshot`);
|
|
66
68
|
}
|
|
67
69
|
const snapshotAvailability = value.availability;
|
|
@@ -78,7 +80,9 @@ function parseSnapshot(value, availability, run, sourceId, sequence, label = 'Pi
|
|
|
78
80
|
...current,
|
|
79
81
|
}] : [];
|
|
80
82
|
return {
|
|
83
|
+
cursor: `bridge:${sequence}:empty`,
|
|
81
84
|
current: current ?? undefined,
|
|
85
|
+
...(typeof value.sourceSequence === 'number' ? { sourceSequence: value.sourceSequence } : {}),
|
|
82
86
|
result: {
|
|
83
87
|
availability: snapshotAvailability,
|
|
84
88
|
...(snapshotAvailability === 'unavailable' ? {} : { snapshot: baseline }),
|
|
@@ -133,16 +137,18 @@ export class BridgeInboxCoordinator {
|
|
|
133
137
|
#agentId;
|
|
134
138
|
#sourceId;
|
|
135
139
|
#label;
|
|
140
|
+
#sourceEventSequence;
|
|
136
141
|
#bindings = new Map();
|
|
137
142
|
#stopConsumer;
|
|
138
143
|
#restoreTail = Promise.resolve();
|
|
139
144
|
#closed = false;
|
|
140
|
-
constructor({ host, projector, agentId = 'pi', sourceId = 'pi.bridge.inbox', label = 'Pi', }) {
|
|
145
|
+
constructor({ host, projector, agentId = 'pi', sourceId = 'pi.bridge.inbox', label = 'Pi', sourceEventSequence, }) {
|
|
141
146
|
this.#host = host;
|
|
142
147
|
this.#projector = projector;
|
|
143
148
|
this.#agentId = agentId;
|
|
144
149
|
this.#sourceId = sourceId;
|
|
145
150
|
this.#label = label;
|
|
151
|
+
this.#sourceEventSequence = sourceEventSequence;
|
|
146
152
|
}
|
|
147
153
|
start() {
|
|
148
154
|
if (this.#closed)
|
|
@@ -265,6 +271,10 @@ export class BridgeInboxCoordinator {
|
|
|
265
271
|
if (!binding || !sameRun(binding.lease.ref, replay.run) || binding.phase === 'opening'
|
|
266
272
|
|| binding.phase === 'closed')
|
|
267
273
|
return 'retry';
|
|
274
|
+
if (this.#historical(binding, operation)) {
|
|
275
|
+
const result = await this.#submitHistory(binding, operation);
|
|
276
|
+
return this.#replayResult(result.accepted, result.reason);
|
|
277
|
+
}
|
|
268
278
|
if (binding.phase === 'prebaseline') {
|
|
269
279
|
if (replay.event.sequence > binding.baselineSequence)
|
|
270
280
|
return 'retry';
|
|
@@ -285,18 +295,53 @@ export class BridgeInboxCoordinator {
|
|
|
285
295
|
return 'retry';
|
|
286
296
|
return 'invalid';
|
|
287
297
|
}
|
|
298
|
+
#historical(binding, operation) {
|
|
299
|
+
if (!this.#sourceEventSequence || !operation.eventId)
|
|
300
|
+
return false;
|
|
301
|
+
const sequence = this.#sourceEventSequence(operation.eventId);
|
|
302
|
+
if (sequence === null)
|
|
303
|
+
return false;
|
|
304
|
+
const current = binding.baseline?.sourceSequence
|
|
305
|
+
?? (binding.baseline?.current?.eventId
|
|
306
|
+
? this.#sourceEventSequence(binding.baseline.current.eventId) : null);
|
|
307
|
+
// An upgrade may expose a pre-existing durable event before any versioned snapshot. It must
|
|
308
|
+
// progress without publishing an unproven old status or waiting for a snapshot behind itself.
|
|
309
|
+
// An explicit equal watermark with a different current identity also proves native state has
|
|
310
|
+
// superseded that Hook. Matching identities must still deliver their real terminal notification.
|
|
311
|
+
return current == null || sequence < current
|
|
312
|
+
|| (binding.baseline?.sourceSequence === sequence
|
|
313
|
+
&& binding.baseline.result.availability !== 'unavailable'
|
|
314
|
+
&& (!binding.baseline.current || (binding.baseline.current.eventId !== undefined
|
|
315
|
+
&& binding.baseline.current.eventId !== operation.eventId)));
|
|
316
|
+
}
|
|
317
|
+
async #submitHistory(binding, operation) {
|
|
318
|
+
if (operation.kind !== 'set' || (operation.state !== 'done' && operation.state !== 'error')
|
|
319
|
+
|| !operation.eventId)
|
|
320
|
+
return { accepted: true };
|
|
321
|
+
return this.#projector.submitTerminalReplay({
|
|
322
|
+
run: binding.lease.ref,
|
|
323
|
+
source: operation.source,
|
|
324
|
+
state: operation.state,
|
|
325
|
+
eventId: operation.eventId,
|
|
326
|
+
historyOnly: true,
|
|
327
|
+
...(operation.message === undefined ? {} : { message: operation.message }),
|
|
328
|
+
...(operation.reason === undefined ? {} : { reason: operation.reason }),
|
|
329
|
+
...(operation.correlationId === undefined ? {} : { correlationId: operation.correlationId }),
|
|
330
|
+
...(operation.sourceOccurredAt === undefined ? {} : { sourceOccurredAt: operation.sourceOccurredAt }),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
288
333
|
#enqueue(binding, operation) {
|
|
289
334
|
const pending = binding.tail.then(operation);
|
|
290
335
|
binding.tail = pending.catch(() => { });
|
|
291
336
|
return pending;
|
|
292
337
|
}
|
|
293
338
|
#restore(result) {
|
|
294
|
-
const pending = this.#restoreTail.then(() => this.#projector.restore(result()).then(() => undefined));
|
|
339
|
+
const pending = this.#restoreTail.then(async () => this.#projector.restore(await result()).then(() => undefined));
|
|
295
340
|
this.#restoreTail = pending.catch(() => { });
|
|
296
341
|
return pending;
|
|
297
342
|
}
|
|
298
343
|
#restoreBindings() {
|
|
299
|
-
return this.#restore(() => {
|
|
344
|
+
return this.#restore(async () => {
|
|
300
345
|
const bindings = [...this.#bindings.values()].filter((binding) => binding.phase !== 'closed');
|
|
301
346
|
const available = bindings.filter((binding) => (binding.baselineReady && binding.baseline?.result.availability !== 'unavailable'));
|
|
302
347
|
const pending = bindings.filter((binding) => !binding.baselineReady || !binding.baseline);
|
|
@@ -309,6 +354,19 @@ export class BridgeInboxCoordinator {
|
|
|
309
354
|
};
|
|
310
355
|
}
|
|
311
356
|
const baselines = available.map((binding) => binding.baseline);
|
|
357
|
+
// A partial restore preserves omitted runs. Claude's explicit versioned empty snapshot proves
|
|
358
|
+
// this particular pane is clear even when a different pane or its historical spool is degraded.
|
|
359
|
+
if (this.#sourceEventSequence)
|
|
360
|
+
for (const binding of available) {
|
|
361
|
+
const baseline = binding.baseline;
|
|
362
|
+
if (baseline.sourceSequence === undefined || baseline.current || !baseline.cursor)
|
|
363
|
+
continue;
|
|
364
|
+
const cleared = await binding.projector.submit({ kind: 'clear', source: {
|
|
365
|
+
sourceId: this.#sourceId, cursor: baseline.cursor,
|
|
366
|
+
} });
|
|
367
|
+
if (!cleared.accepted)
|
|
368
|
+
throw new PiInboxBridgeError(`${this.#label} Inbox empty snapshot could not be persisted`);
|
|
369
|
+
}
|
|
312
370
|
const degraded = baselines.find((baseline) => baseline.result.availability === 'degraded');
|
|
313
371
|
return {
|
|
314
372
|
availability: degraded || pending.length || unavailable.length ? 'degraded' : 'ready',
|
|
@@ -341,7 +399,8 @@ export class BridgeInboxCoordinator {
|
|
|
341
399
|
throw new PiInboxBridgeError(`${this.#label} Inbox Bridge returned an invalid live event`);
|
|
342
400
|
if (isControlOperation(operation))
|
|
343
401
|
return;
|
|
344
|
-
const result =
|
|
402
|
+
const result = this.#historical(binding, operation)
|
|
403
|
+
? await this.#submitHistory(binding, operation) : await binding.projector.submit(operation);
|
|
345
404
|
if (!result.accepted) {
|
|
346
405
|
throw new PiInboxBridgeError(`${this.#label} Inbox live operation rejected: ${result.reason ?? 'unknown'}`);
|
|
347
406
|
}
|
|
@@ -6,20 +6,27 @@ const FAILURE_TTL_MS = 3_000;
|
|
|
6
6
|
// Real executable path of a pid. lsof resolves launch symlinks on macOS; /proc is the cheap Linux path.
|
|
7
7
|
// A transient lsof failure is inconclusive, not a negative identity verdict that should escape the call.
|
|
8
8
|
export async function executablePath(run, pid) {
|
|
9
|
+
if (process.platform === 'linux') {
|
|
10
|
+
try {
|
|
11
|
+
return await fsp.readlink(`/proc/${pid}/exe`);
|
|
12
|
+
}
|
|
13
|
+
catch { /* fall back to lsof */ }
|
|
14
|
+
}
|
|
9
15
|
let out = '';
|
|
10
16
|
try {
|
|
11
17
|
out = await run('lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn']);
|
|
12
18
|
}
|
|
13
|
-
catch { /*
|
|
19
|
+
catch { /* unavailable or timed out */ }
|
|
14
20
|
for (const line of String(out).split('\n'))
|
|
15
21
|
if (line[0] === 'n')
|
|
16
22
|
return line.slice(1).trim();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
if (process.platform !== 'linux') {
|
|
24
|
+
try {
|
|
25
|
+
return await fsp.readlink(`/proc/${pid}/exe`);
|
|
26
|
+
}
|
|
27
|
+
catch { /* unavailable */ }
|
|
22
28
|
}
|
|
29
|
+
return '';
|
|
23
30
|
}
|
|
24
31
|
function parseForegroundProcesses(out) {
|
|
25
32
|
const rows = [];
|
|
@@ -14,6 +14,7 @@ export class ProviderQueryError extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
const DECIMAL = /^\d{1,128}(?:\.\d{1,128})?$/;
|
|
17
|
+
const SIGNED_DECIMAL = /^-?\d{1,128}(?:\.\d{1,128})?$/;
|
|
17
18
|
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
18
19
|
const MAX_MOONSHOT_BALANCE = 1_000_000_000_000_000;
|
|
19
20
|
function isRecord(value) {
|
|
@@ -52,8 +53,8 @@ async function boundedResponseText(response) {
|
|
|
52
53
|
}
|
|
53
54
|
return new TextDecoder().decode(bytes);
|
|
54
55
|
}
|
|
55
|
-
function decimalString(value) {
|
|
56
|
-
return typeof value === 'string' && DECIMAL.test(value);
|
|
56
|
+
function decimalString(value, allowNegative = false) {
|
|
57
|
+
return typeof value === 'string' && (allowNegative ? SIGNED_DECIMAL : DECIMAL).test(value);
|
|
57
58
|
}
|
|
58
59
|
function boundedAmount(value, allowNegative = false) {
|
|
59
60
|
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= MAX_MOONSHOT_BALANCE
|
|
@@ -69,7 +70,7 @@ function parseDeepSeekBalance(value) {
|
|
|
69
70
|
|| !hasExactKeys(candidate, ['currency', 'total_balance', 'topped_up_balance', 'granted_balance'])
|
|
70
71
|
|| typeof candidate.currency !== 'string'
|
|
71
72
|
|| candidate.currency.length < 1 || candidate.currency.length > 32
|
|
72
|
-
|| !decimalString(candidate.total_balance)
|
|
73
|
+
|| !decimalString(candidate.total_balance, true)
|
|
73
74
|
|| !decimalString(candidate.topped_up_balance)
|
|
74
75
|
|| !decimalString(candidate.granted_balance)) {
|
|
75
76
|
throw new ProviderQueryError('unsupported_response');
|
|
@@ -160,7 +161,7 @@ function validDeepSeekResult(value) {
|
|
|
160
161
|
&& value.balances.every((balance) => isRecord(balance)
|
|
161
162
|
&& hasExactKeys(balance, ['currency', 'totalBalance', 'toppedUpBalance', 'grantedBalance'])
|
|
162
163
|
&& typeof balance.currency === 'string' && balance.currency.length >= 1 && balance.currency.length <= 32
|
|
163
|
-
&& decimalString(balance.totalBalance) && decimalString(balance.toppedUpBalance)
|
|
164
|
+
&& decimalString(balance.totalBalance, true) && decimalString(balance.toppedUpBalance)
|
|
164
165
|
&& decimalString(balance.grantedBalance));
|
|
165
166
|
}
|
|
166
167
|
function validMoonshotResult(value) {
|
|
@@ -18,6 +18,11 @@ export function createBrowserBootstrapStore({ randomToken = () => randomBytes(24
|
|
|
18
18
|
tickets.delete(token);
|
|
19
19
|
};
|
|
20
20
|
return {
|
|
21
|
+
revokeDevice(deviceId) {
|
|
22
|
+
for (const [token, entry] of tickets)
|
|
23
|
+
if (entry.deviceId === deviceId)
|
|
24
|
+
tickets.delete(token);
|
|
25
|
+
},
|
|
21
26
|
issue({ url, origin, deviceId, preserveMethod = false, redirectStatus = 302, }) {
|
|
22
27
|
prune();
|
|
23
28
|
const expectedOrigin = normalizedOrigin(origin);
|