handmux 0.25.0 → 0.25.2
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/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/dist/package.json +13 -13
- package/dist/public/assets/{index-DmjeMSQA.js → index-DH4DnhbV.js} +98 -98
- package/dist/public/assets/{index-CTrqTqtT.css → index-DPBerEuM.css} +1 -1
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/conversationLiveHub.js +13 -5
- package/dist/src/agent-runtime/interactionLiveHub.js +6 -1
- package/dist/src/agent-runtime/runtime.js +52 -10
- package/dist/src/agents/conversationProjectionSafety.js +41 -11
- package/dist/src/codexAppServer.js +8 -4
- package/dist/src/docs.js +16 -5
- package/package.json +4 -4
package/dist/public/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-DH4DnhbV.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DPBerEuM.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-DPBerEuM.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -168,6 +168,7 @@ export class ConversationLiveHub {
|
|
|
168
168
|
ready: Promise.resolve(),
|
|
169
169
|
subscribers: new Set(),
|
|
170
170
|
replay: [],
|
|
171
|
+
provisional: new Set(),
|
|
171
172
|
idleTimer: undefined,
|
|
172
173
|
detachAbort: () => { },
|
|
173
174
|
terminated: false,
|
|
@@ -201,8 +202,9 @@ export class ConversationLiveHub {
|
|
|
201
202
|
|| event.sequence <= shared.checkpoint.streamSequence)
|
|
202
203
|
return;
|
|
203
204
|
const previousSequence = shared.checkpoint.streamSequence;
|
|
205
|
+
const advancesBaseline = event.type === 'history.changed' && shared.provisional.size === 0;
|
|
204
206
|
if (shared.replay.length >= this.#maxReplayEvents
|
|
205
|
-
&&
|
|
207
|
+
&& !advancesBaseline && event.type !== 'stream.gap') {
|
|
206
208
|
const gap = {
|
|
207
209
|
type: 'stream.gap', sequence: event.sequence, afterSequence: previousSequence,
|
|
208
210
|
};
|
|
@@ -222,10 +224,16 @@ export class ConversationLiveHub {
|
|
|
222
224
|
shared.replay.push(structuredClone(event));
|
|
223
225
|
for (const subscriber of [...shared.subscribers])
|
|
224
226
|
subscriber.push(event, terminal);
|
|
225
|
-
if (event.type === '
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
if (event.type === 'item.opened')
|
|
228
|
+
shared.provisional.add(event.provisionalId);
|
|
229
|
+
else if (event.type === 'item.settled' || event.type === 'item.cancelled') {
|
|
230
|
+
shared.provisional.delete(event.provisionalId);
|
|
231
|
+
}
|
|
232
|
+
else if (event.type === 'stream.gap')
|
|
233
|
+
shared.provisional.clear();
|
|
234
|
+
if (advancesBaseline) {
|
|
235
|
+
// A barrier can release replay only when no provisional lifecycle crosses it. Otherwise a late
|
|
236
|
+
// subscriber must replay item.opened before receiving that item's later delta or settlement.
|
|
229
237
|
shared.baseline = structuredClone(shared.checkpoint);
|
|
230
238
|
shared.replay = [];
|
|
231
239
|
}
|
|
@@ -165,10 +165,15 @@ export class InteractionLiveHub {
|
|
|
165
165
|
}
|
|
166
166
|
const pending = new Map(checkpoint.pending.map((item) => [item.id, item]));
|
|
167
167
|
if (event.type === 'opened') {
|
|
168
|
-
|
|
168
|
+
const previous = pending.get(event.interaction.id);
|
|
169
|
+
if (event.interaction.runId !== shared.run.ref.runId
|
|
170
|
+
|| (previous !== undefined
|
|
171
|
+
&& previous.resolutionToken !== event.interaction.resolutionToken)) {
|
|
169
172
|
this.#terminate(shared);
|
|
170
173
|
throw new InteractionLiveHubError('unavailable', 'Interaction opened event is invalid');
|
|
171
174
|
}
|
|
175
|
+
// Core deliberately reuses the public identity and resolution token when a native pending item
|
|
176
|
+
// changes in place. Mirror its upsert contract so prompt/option updates do not tear down SSE.
|
|
172
177
|
pending.set(event.interaction.id, structuredClone(event.interaction));
|
|
173
178
|
}
|
|
174
179
|
else if (!pending.delete(event.interactionId)) {
|
|
@@ -155,6 +155,7 @@ export class AgentRuntime {
|
|
|
155
155
|
#transport;
|
|
156
156
|
#unsubscribePanes;
|
|
157
157
|
#reconcileTail = Promise.resolve();
|
|
158
|
+
#startPromise;
|
|
158
159
|
#started = false;
|
|
159
160
|
#closed = false;
|
|
160
161
|
#closePromise;
|
|
@@ -545,18 +546,58 @@ export class AgentRuntime {
|
|
|
545
546
|
throw new Error('AgentRuntime is closed');
|
|
546
547
|
if (this.#started)
|
|
547
548
|
return;
|
|
548
|
-
this.#
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
549
|
+
if (this.#startPromise)
|
|
550
|
+
return this.#startPromise;
|
|
551
|
+
const operation = this.#start();
|
|
552
|
+
this.#startPromise = operation;
|
|
553
|
+
try {
|
|
554
|
+
await operation;
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
if (this.#startPromise === operation)
|
|
558
|
+
this.#startPromise = undefined;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async #start() {
|
|
562
|
+
let unsubscribe;
|
|
563
|
+
try {
|
|
564
|
+
// Establish the only fallible shared prerequisite before adapters acquire resources. Connections
|
|
565
|
+
// arriving inside this short starting window fail closed and can retry once #started is committed.
|
|
566
|
+
await this.#transport.start();
|
|
567
|
+
if (this.#closed)
|
|
568
|
+
throw new Error('AgentRuntime is closed');
|
|
569
|
+
unsubscribe = this.#panes.subscribe((snapshot) => {
|
|
570
|
+
const operation = this.#reconcileTail.then(() => this.#reconcile(snapshot));
|
|
571
|
+
this.#reconcileTail = operation.catch((error) => {
|
|
572
|
+
this.#logger.warn('Agent pane reconciliation failed', {
|
|
573
|
+
error: error instanceof Error ? error.message : String(error),
|
|
574
|
+
});
|
|
557
575
|
});
|
|
558
576
|
});
|
|
559
|
-
|
|
577
|
+
this.#unsubscribePanes = unsubscribe;
|
|
578
|
+
for (const adapter of this.adapters) {
|
|
579
|
+
if (this.#closed)
|
|
580
|
+
throw new Error('AgentRuntime is closed');
|
|
581
|
+
await this.#startAdapter(adapter);
|
|
582
|
+
}
|
|
583
|
+
if (this.#closed)
|
|
584
|
+
throw new Error('AgentRuntime is closed');
|
|
585
|
+
this.#started = true;
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
unsubscribe?.();
|
|
589
|
+
if (this.#unsubscribePanes === unsubscribe)
|
|
590
|
+
this.#unsubscribePanes = undefined;
|
|
591
|
+
await this.#transport.close();
|
|
592
|
+
if (!this.#closed) {
|
|
593
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
594
|
+
for (const adapter of this.adapters)
|
|
595
|
+
this.#report(adapter.id, {
|
|
596
|
+
availability: 'unavailable', message,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
560
601
|
}
|
|
561
602
|
close() {
|
|
562
603
|
if (this.#closePromise)
|
|
@@ -568,6 +609,7 @@ export class AgentRuntime {
|
|
|
568
609
|
if (this.#closed)
|
|
569
610
|
return;
|
|
570
611
|
this.#closed = true;
|
|
612
|
+
await this.#startPromise?.catch(() => { });
|
|
571
613
|
this.#unsubscribePanes?.();
|
|
572
614
|
this.#unsubscribePanes = undefined;
|
|
573
615
|
await this.#reconcileTail.catch(() => { });
|
|
@@ -97,14 +97,35 @@ export function safeProviderPathLabel(value) {
|
|
|
97
97
|
return relative;
|
|
98
98
|
if (typeof value !== 'string' || value.length === 0)
|
|
99
99
|
return undefined;
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
let path = value;
|
|
101
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) {
|
|
102
|
+
return path.split(/[\\/]/).includes('..') ? undefined : path.replaceAll('\\', '/');
|
|
102
103
|
}
|
|
103
|
-
if (
|
|
104
|
+
if (/^file:/i.test(path)) {
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(path);
|
|
107
|
+
if (url.protocol !== 'file:' || (url.hostname && url.hostname !== 'localhost'))
|
|
108
|
+
return undefined;
|
|
109
|
+
path = decodeURIComponent(url.pathname);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const windows = path.match(/^([a-zA-Z]:)[\\/]Users[\\/][^\\/]+(?<tail>(?:[\\/].*)?)$/i);
|
|
116
|
+
if (windows)
|
|
117
|
+
path = windows.groups?.tail ? `~${windows.groups.tail.replaceAll('\\', '/')}` : '~';
|
|
118
|
+
else
|
|
119
|
+
path = path
|
|
120
|
+
.replace(/^\/Users\/[^/]+(?=\/|$)/, '~')
|
|
121
|
+
.replace(/^\/home\/[^/]+(?=\/|$)/, '~')
|
|
122
|
+
.replace(/^\/root(?=\/|$)/, '~');
|
|
123
|
+
if (!(path === '~' || path.startsWith('~/') || path.startsWith('/')
|
|
124
|
+
|| /^[a-zA-Z]:[\\/]/.test(path)))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (path.split(/[\\/]/).includes('..'))
|
|
104
127
|
return undefined;
|
|
105
|
-
|
|
106
|
-
return leaf && leaf !== '.' && leaf !== '..' && safeRelativeProviderPath(leaf) === leaf
|
|
107
|
-
? leaf : undefined;
|
|
128
|
+
return path;
|
|
108
129
|
}
|
|
109
130
|
function urlContainsPrivateData(value) {
|
|
110
131
|
try {
|
|
@@ -148,8 +169,9 @@ function safePublicEndpoint(value) {
|
|
|
148
169
|
}
|
|
149
170
|
}
|
|
150
171
|
/**
|
|
151
|
-
* Redacts
|
|
152
|
-
*
|
|
172
|
+
* Redacts credentials and private endpoints from otherwise useful command output. Local paths stay
|
|
173
|
+
* readable because this authenticated self-hosted UI exposes the same workspace through its terminal;
|
|
174
|
+
* conventional user-home prefixes are abbreviated to avoid repeating account names.
|
|
153
175
|
*/
|
|
154
176
|
export function sanitizeToolText(value) {
|
|
155
177
|
let redacted = false;
|
|
@@ -157,14 +179,22 @@ export function sanitizeToolText(value) {
|
|
|
157
179
|
redacted = true;
|
|
158
180
|
return `${prefix}[redacted]`;
|
|
159
181
|
};
|
|
182
|
+
const keepPath = (match, prefix, path) => {
|
|
183
|
+
const safe = safeProviderPathLabel(path);
|
|
184
|
+
if (safe === undefined)
|
|
185
|
+
return replace(match, prefix);
|
|
186
|
+
if (safe !== path)
|
|
187
|
+
redacted = true;
|
|
188
|
+
return `${prefix}${safe}`;
|
|
189
|
+
};
|
|
160
190
|
let text = value
|
|
161
|
-
.replace(/\b(?:file):\/\/[^\s<>'"`]+/gi, (match) =>
|
|
191
|
+
.replace(/\b(?:file):\/\/[^\s<>'"`]+/gi, (match) => keepPath(match, '', match))
|
|
162
192
|
.replace(/\b(?:https?|wss?):\/\/[^\s<>'"`]+/gi, (match) => (urlContainsPrivateData(match) ? replace(match) : match))
|
|
163
193
|
.replace(/(\b(?:authorization|proxy-authorization)\b\s*[:=]\s*)(?:bearer\s+)?[^\s,;}]+/gi, (match, prefix) => replace(match, prefix))
|
|
164
194
|
.replace(/(\b(?:cookie|set-cookie|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|password|passwd|credential|client[_-]?secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, (match, prefix) => replace(match, prefix))
|
|
165
195
|
.replace(/(\b(?:endpoint|base[_-]?url|server[_-]?url|socket(?:[_-]?path)?)\b\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;}]+)/gi, (match, prefix, endpoint) => (privateEndpointText(endpoint) ? replace(match, prefix) : match))
|
|
166
|
-
.replace(/(^|[\s"'=(+\-])([a-zA-Z]:[\\/][^\s"'`,;)\]}]+)/g, (match, prefix) =>
|
|
167
|
-
.replace(/(^|[\s"'=(+\-])(\/(?!\/)[^\s"'`,;)\]}]+)/g, (match, prefix) =>
|
|
196
|
+
.replace(/(^|[\s"'=(+\-])([a-zA-Z]:[\\/][^\s"'`,;)\]}]+)/g, (match, prefix, path) => keepPath(match, prefix, path))
|
|
197
|
+
.replace(/(^|[\s"'=(+\-])(\/(?!\/)[^\s"'`,;)\]}]+)/g, (match, prefix, path) => keepPath(match, prefix, path));
|
|
168
198
|
return { text, redacted };
|
|
169
199
|
}
|
|
170
200
|
function safeToolValue(value, key, depth, state) {
|
|
@@ -784,6 +784,9 @@ class CodexAppConnection {
|
|
|
784
784
|
if (message.method === 'item/started' && params.item.type === 'contextCompaction') {
|
|
785
785
|
state.compacting = true;
|
|
786
786
|
}
|
|
787
|
+
else if (message.method === 'item/completed' && params.item.type === 'contextCompaction') {
|
|
788
|
+
state.compacting = false;
|
|
789
|
+
}
|
|
787
790
|
if (params.item.type === 'agentMessage') {
|
|
788
791
|
const itemKey = `${params.turnId}\0${params.item.id}`;
|
|
789
792
|
if (message.method === 'item/completed')
|
|
@@ -941,9 +944,10 @@ class CodexAppConnection {
|
|
|
941
944
|
}
|
|
942
945
|
else if (message.method === 'thread/started') {
|
|
943
946
|
const startedThreadId = params.thread?.id || params.threadId || null;
|
|
944
|
-
// Collaboration
|
|
945
|
-
// replacement for the root
|
|
946
|
-
if (startedThreadId && params.thread?.parentThreadId == null
|
|
947
|
+
// Collaboration children and ephemeral helpers share this App Server connection. They are
|
|
948
|
+
// independent work, not a replacement for the durable root conversation represented by this pane.
|
|
949
|
+
if (startedThreadId && params.thread?.parentThreadId == null
|
|
950
|
+
&& params.thread?.ephemeral !== true) {
|
|
947
951
|
const previous = this.currentThreadId;
|
|
948
952
|
this.lastStartedThreadId = startedThreadId;
|
|
949
953
|
this.currentThreadId = startedThreadId;
|
|
@@ -2489,7 +2493,7 @@ class CodexAppConnection {
|
|
|
2489
2493
|
return {
|
|
2490
2494
|
threadId,
|
|
2491
2495
|
order: Number(thread.updatedAt ?? thread.createdAt ?? index),
|
|
2492
|
-
root: thread.parentThreadId == null,
|
|
2496
|
+
root: thread.parentThreadId == null && thread.ephemeral !== true,
|
|
2493
2497
|
known: true,
|
|
2494
2498
|
};
|
|
2495
2499
|
}
|
package/dist/src/docs.js
CHANGED
|
@@ -41,6 +41,15 @@ export function encodeCwdKey(cwd) {
|
|
|
41
41
|
return cwd.replace(/\//g, '-').replace(/[^A-Za-z0-9._-]/g, '_') || '_default';
|
|
42
42
|
}
|
|
43
43
|
export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANSFER_BYTES }) {
|
|
44
|
+
const rootedClientPath = (rawPath) => {
|
|
45
|
+
if (typeof rawPath !== 'string')
|
|
46
|
+
return null;
|
|
47
|
+
if (rawPath === '~')
|
|
48
|
+
return home;
|
|
49
|
+
if (rawPath.startsWith('~/'))
|
|
50
|
+
return join(home, rawPath.slice(2));
|
|
51
|
+
return rawPath.startsWith('/') ? rawPath : null;
|
|
52
|
+
};
|
|
44
53
|
// $HOME is constant at runtime — resolve once at construction, reuse the same promise everywhere.
|
|
45
54
|
const realHomeP = fs.realpath(home);
|
|
46
55
|
// Browse / read / download / upload may also reach a few EXTRA roots OUTSIDE $HOME (e.g. /tmp,
|
|
@@ -80,14 +89,15 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
|
|
|
80
89
|
// the phone neither transfers content nor forces a re-render. `mtimeMs` is always returned so the
|
|
81
90
|
// client can store it for the next check.
|
|
82
91
|
async function readDoc(rawPath, knownMtime = null) {
|
|
83
|
-
|
|
92
|
+
const requestedPath = rootedClientPath(rawPath);
|
|
93
|
+
if (!requestedPath)
|
|
84
94
|
return { error: 'not absolute', status: 400 };
|
|
85
95
|
// Markdown/HTML keep their rich renderer; every other filename gets a content-checked plain-text
|
|
86
96
|
// preview. Extension is presentation metadata, never the permission to read a file as text.
|
|
87
|
-
const type = docTypeFor(
|
|
97
|
+
const type = docTypeFor(requestedPath) || 'text';
|
|
88
98
|
let real;
|
|
89
99
|
try {
|
|
90
|
-
real = await fs.realpath(
|
|
100
|
+
real = await fs.realpath(requestedPath);
|
|
91
101
|
}
|
|
92
102
|
catch {
|
|
93
103
|
return { error: 'not found', status: 404 };
|
|
@@ -162,11 +172,12 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
|
|
|
162
172
|
// Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
|
|
163
173
|
// readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
|
|
164
174
|
async function statForDownload(rawPath) {
|
|
165
|
-
|
|
175
|
+
const requestedPath = rootedClientPath(rawPath);
|
|
176
|
+
if (!requestedPath)
|
|
166
177
|
return { error: 'not absolute', status: 400 };
|
|
167
178
|
let real;
|
|
168
179
|
try {
|
|
169
|
-
real = await fs.realpath(
|
|
180
|
+
real = await fs.realpath(requestedPath);
|
|
170
181
|
}
|
|
171
182
|
catch {
|
|
172
183
|
return { error: 'not found', status: 404 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handmux",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "A mobile vibe-coding cockpit — built on tmux: drive your live session, Claude Code / Codex — anything a terminal can run — from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -75,13 +75,13 @@
|
|
|
75
75
|
"vitest": "^2.0.0"
|
|
76
76
|
},
|
|
77
77
|
"whatsNew": [
|
|
78
|
+
{"version":"0.25.2","date":"2026-09-02","zh":"修复 Agent 对话断流、排队与工具路径","en":"Fixed Agent chat streams, queues, and tool paths"},
|
|
79
|
+
{"version":"0.25.1","date":"2026-09-02","zh":"修复 Codex 幽灵任务与对话空白","en":"Fixed Codex ghost tasks and blank chats"},
|
|
78
80
|
{"version":"0.25.0","date":"2026-09-01","zh":"Pi 原生接入 · 多 Agent 对话更可靠","en":"Native Pi support · more reliable agent chat"},
|
|
79
81
|
{"version":"0.24.0","date":"2026-08-11","zh":"Codex 任务进度与 Goal 原生上线","en":"Native Codex task progress and Goals"},
|
|
80
82
|
{"version":"0.23.0","date":"2026-08-09","zh":"Codex 托管对话正式上线","en":"Managed Codex chat is here"},
|
|
81
83
|
{"version":"0.22.1","date":"2026-08-04","zh":"网页宽度与网站版本分开控制","en":"Separate page width and site-version controls"},
|
|
82
84
|
{"version":"0.22.0","date":"2026-08-03","zh":"全屏设置 · 窗口与分屏管理更清晰","en":"Full-screen Settings · clearer window and pane management"},
|
|
83
|
-
{"version":"0.21.0","date":"2026-08-03","zh":"网页预览:手机直连、电脑代理与静态目录","en":"Web preview: direct, computer proxy, and static folders"}
|
|
84
|
-
{"version":"0.20.3","date":"2026-08-01","zh":"实时终端显示更稳定流畅","en":"More stable, smoother live terminals"},
|
|
85
|
-
{"version":"0.20.2","date":"2026-07-27","zh":"实时终端长时间运行更流畅","en":"Smoother long-running live terminals"}
|
|
85
|
+
{"version":"0.21.0","date":"2026-08-03","zh":"网页预览:手机直连、电脑代理与静态目录","en":"Web preview: direct, computer proxy, and static folders"}
|
|
86
86
|
]
|
|
87
87
|
}
|