handmux 0.25.1 → 0.26.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.
@@ -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-BR0Du_wl.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-CTrqTqtT.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CTrqTqtT.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-KDa5PCoi.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-BVFKs5dN.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BVFKs5dN.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -6,6 +6,7 @@ import { createCodexNativeInboxSource, } from '../agents/nativeInboxSources.js';
6
6
  import { createCodexConversationAdapter } from '../agents/codexConversation.js';
7
7
  import { createCodexInteractionAdapter } from '../agents/codexInteraction.js';
8
8
  import { createPiConversationActivityReader, createPiConversationAdapter, } from '../agents/piConversation.js';
9
+ import { createPiConversationContextAdapter } from '../agents/piConversationContext.js';
9
10
  import { createPiSessionControlAdapter } from '../agents/piSessionControl.js';
10
11
  import { createCodexSessionControlAdapter } from '../agents/codexSessionControl.js';
11
12
  import { createCodexConversationControls } from '../agents/codexConversationControls.js';
@@ -128,6 +129,7 @@ export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRo
128
129
  ...(piSessionsRoot === undefined ? {} : { sessionsRoot: piSessionsRoot }),
129
130
  }),
130
131
  conversationActivity: createPiConversationActivityReader(context.bridge),
132
+ conversationContext: createPiConversationContextAdapter({ host: context.bridge }),
131
133
  sessionControl: createPiSessionControlAdapter({ host: context.bridge }),
132
134
  start: () => {
133
135
  inbox.start();
@@ -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
- && event.type !== 'history.changed' && event.type !== 'stream.gap') {
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 === 'history.changed') {
226
- // A durable barrier advances the baseline without ending the native observation. Existing clients
227
- // receive the barrier and reconcile their page; later subscribers start directly from the new
228
- // checkpoint, so pre-barrier replay can be released immediately.
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
- if (event.interaction.runId !== shared.run.ref.runId || pending.has(event.interaction.id)) {
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.#started = true;
549
- for (const adapter of this.adapters)
550
- await this.#startAdapter(adapter);
551
- await this.#transport.start();
552
- this.#unsubscribePanes = this.#panes.subscribe((snapshot) => {
553
- const operation = this.#reconcileTail.then(() => this.#reconcile(snapshot));
554
- this.#reconcileTail = operation.catch((error) => {
555
- this.#logger.warn('Agent pane reconciliation failed', {
556
- error: error instanceof Error ? error.message : String(error),
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(() => { });
@@ -5,7 +5,7 @@ import { createTranscriptParser } from '../transcriptParse.js';
5
5
  import { transcriptReader } from '../transcriptReader.js';
6
6
  import { projectsDir } from './claude.js';
7
7
  import { isSessionUuid } from './scanUtils.js';
8
- import { clipConversationText as clipped, safeProviderPathLabel, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
8
+ import { clipConversationText as clipped, safeProviderDiffPath, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
9
9
  function isRun(target) {
10
10
  return 'runId' in target;
11
11
  }
@@ -151,7 +151,7 @@ function toolItems(message, sessionId, id, tool) {
151
151
  const input = tool.input && typeof tool.input === 'object' && !Array.isArray(tool.input)
152
152
  ? tool.input : {};
153
153
  const rawPath = input.file_path ?? input.path;
154
- const providerPath = safeProviderPathLabel(rawPath);
154
+ const providerPath = safeProviderDiffPath(rawPath);
155
155
  const patch = diffPatch(tool);
156
156
  items.push({
157
157
  ...itemBase(message, sessionId, `${id}:diff`),
@@ -5,7 +5,7 @@ import { createCodexTranscriptParser, } from '../codexTranscriptParse.js';
5
5
  import { projectCodexConversationMutation } from '../codexConversationProjection.js';
6
6
  import { parseCodexStreamEvent } from '../codexStreamProtocol.js';
7
7
  import { resolveCodexRollout, sessionsDir } from './codex.js';
8
- import { clipConversationText, safeProviderPathLabel, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
8
+ import { clipConversationText, safeProviderDiffPath, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
9
9
  const DURABLE_QUIET_MS = 100;
10
10
  const DEFAULT_DURABLE_SETTLE_TIMEOUT_MS = 15_000;
11
11
  const MAX_OPENING_NATIVE_EVENTS = 4_096;
@@ -381,7 +381,7 @@ function projectDurableMessage(message, sessionId) {
381
381
  && !Array.isArray(message.tool.input) ? message.tool.input : {};
382
382
  if (message.tool.diff) {
383
383
  const rawPath = rawInput.file_path ?? rawInput.path;
384
- const path = safeProviderPathLabel(rawPath);
384
+ const path = safeProviderDiffPath(rawPath);
385
385
  const patch = typeof rawInput.patch === 'string'
386
386
  ? sanitizeToolResultText(rawInput.patch) : undefined;
387
387
  items.push({
@@ -33,8 +33,11 @@ function permissionMode(settings) {
33
33
  if ((sandbox === 'dangerFullAccess' || sandbox === 'danger-full-access') && approval === 'never') {
34
34
  return 'full-access';
35
35
  }
36
+ if (approval === 'on-request'
37
+ && (reviewer === 'auto_review' || reviewer === 'guardian_subagent'))
38
+ return 'auto-review';
36
39
  if ((sandbox === 'workspaceWrite' || sandbox === 'workspace-write') && approval === 'on-request') {
37
- return reviewer === 'auto_review' || reviewer === 'guardian_subagent' ? 'auto-review' : 'default';
40
+ return reviewer === undefined || reviewer === 'user' ? 'default' : 'custom';
38
41
  }
39
42
  return 'custom';
40
43
  }
@@ -97,14 +97,42 @@ export function safeProviderPathLabel(value) {
97
97
  return relative;
98
98
  if (typeof value !== 'string' || value.length === 0)
99
99
  return undefined;
100
- if (!(value.startsWith('/') || value.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(value))) {
101
- return undefined;
100
+ let path = value;
101
+ if (path.startsWith('~/') || path.startsWith('~\\')) {
102
+ return path.split(/[\\/]/).includes('..') ? undefined : path.replaceAll('\\', '/');
103
+ }
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
+ }
102
114
  }
103
- if (value.split(/[\\/]/).includes('..'))
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)))
104
125
  return undefined;
105
- const leaf = value.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1);
106
- return leaf && leaf !== '.' && leaf !== '..' && safeRelativeProviderPath(leaf) === leaf
107
- ? leaf : undefined;
126
+ if (path.split(/[\\/]/).includes('..'))
127
+ return undefined;
128
+ return path;
129
+ }
130
+ export function safeProviderDiffPath(value) {
131
+ const label = safeProviderPathLabel(value);
132
+ if (label === undefined || label.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(label)) {
133
+ return undefined;
134
+ }
135
+ return label;
108
136
  }
109
137
  function urlContainsPrivateData(value) {
110
138
  try {
@@ -148,8 +176,9 @@ function safePublicEndpoint(value) {
148
176
  }
149
177
  }
150
178
  /**
151
- * Redacts provider-local data from otherwise useful command output. This intentionally keeps commands,
152
- * relative paths, and ordinary public URLs readable.
179
+ * Redacts credentials and private endpoints from otherwise useful command output. Local paths stay
180
+ * readable because this authenticated self-hosted UI exposes the same workspace through its terminal;
181
+ * conventional user-home prefixes are abbreviated to avoid repeating account names.
153
182
  */
154
183
  export function sanitizeToolText(value) {
155
184
  let redacted = false;
@@ -157,14 +186,22 @@ export function sanitizeToolText(value) {
157
186
  redacted = true;
158
187
  return `${prefix}[redacted]`;
159
188
  };
189
+ const keepPath = (match, prefix, path) => {
190
+ const safe = safeProviderPathLabel(path);
191
+ if (safe === undefined)
192
+ return replace(match, prefix);
193
+ if (safe !== path)
194
+ redacted = true;
195
+ return `${prefix}${safe}`;
196
+ };
160
197
  let text = value
161
- .replace(/\b(?:file):\/\/[^\s<>'"`]+/gi, (match) => replace(match))
198
+ .replace(/\b(?:file):\/\/[^\s<>'"`]+/gi, (match) => keepPath(match, '', match))
162
199
  .replace(/\b(?:https?|wss?):\/\/[^\s<>'"`]+/gi, (match) => (urlContainsPrivateData(match) ? replace(match) : match))
163
200
  .replace(/(\b(?:authorization|proxy-authorization)\b\s*[:=]\s*)(?:bearer\s+)?[^\s,;}]+/gi, (match, prefix) => replace(match, prefix))
164
201
  .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
202
  .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) => replace(match, prefix))
167
- .replace(/(^|[\s"'=(+\-])(\/(?!\/)[^\s"'`,;)\]}]+)/g, (match, prefix) => replace(match, prefix));
203
+ .replace(/(^|[\s"'=(+\-])([a-zA-Z]:[\\/][^\s"'`,;)\]}]+)/g, (match, prefix, path) => keepPath(match, prefix, path))
204
+ .replace(/(^|[\s"'=(+\-])(\/(?!\/)[^\s"'`,;)\]}]+)/g, (match, prefix, path) => keepPath(match, prefix, path));
168
205
  return { text, redacted };
169
206
  }
170
207
  function safeToolValue(value, key, depth, state) {
@@ -22,6 +22,7 @@ export const piAgentAdapter = {
22
22
  capabilities: {
23
23
  inbox: { apiVersion: 1 },
24
24
  conversation: { apiVersion: 1, experimental: true },
25
+ conversationContext: { apiVersion: 1 },
25
26
  sessionControl: { apiVersion: 1 },
26
27
  },
27
28
  presentation: { iconId: 'pi' },
@@ -0,0 +1,49 @@
1
+ const PI_CONTEXT_IMPLEMENTATION_VERSION = 7;
2
+ function record(value) {
3
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
4
+ ? value : null;
5
+ }
6
+ function finite(value) {
7
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
8
+ }
9
+ function contextSnapshot(value) {
10
+ const snapshot = record(value);
11
+ if (!snapshot || !['idle', 'working', 'waiting', 'compacting', 'unknown']
12
+ .includes(String(snapshot.activity))) {
13
+ throw new Error('Pi Extension returned an invalid Conversation Context snapshot');
14
+ }
15
+ const hasUsedTokens = snapshot.usedTokens !== undefined;
16
+ const hasTotalTokens = snapshot.totalTokens !== undefined;
17
+ if (hasUsedTokens !== hasTotalTokens
18
+ || (hasUsedTokens && (!finite(snapshot.usedTokens)
19
+ || !finite(snapshot.totalTokens) || snapshot.totalTokens <= 0))) {
20
+ throw new Error('Pi Extension returned invalid context usage');
21
+ }
22
+ if (snapshot.cwd !== undefined
23
+ && (typeof snapshot.cwd !== 'string' || !snapshot.cwd.trim())) {
24
+ throw new Error('Pi Extension returned an invalid working directory');
25
+ }
26
+ return {
27
+ activity: snapshot.activity,
28
+ ...(hasUsedTokens ? {
29
+ usedTokens: snapshot.usedTokens,
30
+ totalTokens: snapshot.totalTokens,
31
+ } : {}),
32
+ ...(typeof snapshot.cwd === 'string' ? { cwd: snapshot.cwd } : {}),
33
+ };
34
+ }
35
+ export function createPiConversationContextAdapter({ host, }) {
36
+ if (!host || typeof host.request !== 'function') {
37
+ throw new TypeError('Pi Conversation Context adapter requires LocalAgentBridgeHost');
38
+ }
39
+ return {
40
+ apiVersion: 1,
41
+ async read(run) {
42
+ // Context is optional for pre-v7 Connectors. Avoid probing an unregistered handler so their
43
+ // otherwise healthy Conversation and Inbox capabilities remain usable until Pi is reloaded.
44
+ if ((run.ref.implementationVersion ?? 1) < PI_CONTEXT_IMPLEMENTATION_VERSION)
45
+ return null;
46
+ return contextSnapshot(await host.request(run, 'conversation', 'context', {}, { timeoutMs: 8_000, signal: run.signal }));
47
+ },
48
+ };
49
+ }
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  const MANAGED_MARK = '// handmux-managed-pi-extension:v1';
6
7
  const ENTRY_MARK = '// handmux-entry:';
8
+ const NATIVE_IMPORT_MODULE_URL = 'data:text/javascript;base64,'
9
+ + 'ZXhwb3J0IGZ1bmN0aW9uIGltcG9ydFBpQ29ubmVjdG9yKHNwZWNpZmllcikgeyByZXR1cm4gaW1wb3J0KHNwZWNpZmllcik7IH0K';
7
10
  export class PiExtensionInstallError extends Error {
8
11
  code;
9
12
  constructor(code, message) {
@@ -59,11 +62,18 @@ function expectedEntryUrl(entryFile) {
59
62
  }
60
63
  function wrapper(entryFile) {
61
64
  const entryUrl = expectedEntryUrl(entryFile);
65
+ const fingerprint = createHash('sha256').update(fs.readFileSync(entryFile)).digest('hex');
66
+ const importUrl = `${entryUrl}?handmux=${fingerprint}`;
62
67
  return [
63
68
  MANAGED_MARK,
64
69
  `${ENTRY_MARK}${entryUrl}`,
65
70
  '// This tiny wrapper is owned by Handmux. Pi loads it from its documented global extension path.',
66
- `export { default } from ${JSON.stringify(entryUrl)};`,
71
+ '// Native ESM import preserves the fingerprint that Pi\'s jiti strips from static re-exports.',
72
+ `import { importPiConnector } from ${JSON.stringify(NATIVE_IMPORT_MODULE_URL)};`,
73
+ 'export default async function handmux(api) {',
74
+ ` const connector = await importPiConnector(${JSON.stringify(importUrl)});`,
75
+ ' return connector.default(api);',
76
+ '}',
67
77
  '',
68
78
  ].join('\n');
69
79
  }
@@ -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')
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
- if (typeof rawPath !== 'string' || rawPath[0] !== '/')
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(rawPath) || 'text';
97
+ const type = docTypeFor(requestedPath) || 'text';
88
98
  let real;
89
99
  try {
90
- real = await fs.realpath(rawPath);
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
- if (typeof rawPath !== 'string' || rawPath[0] !== '/')
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(rawPath);
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.1",
3
+ "version": "0.26.0",
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.26.0","date":"2026-09-04","zh":"对话精细复制 · Pi 上下文状态","en":"Precise chat copy · Pi context status"},
79
+ {"version":"0.25.2","date":"2026-09-02","zh":"修复 Agent 对话断流、排队与工具路径","en":"Fixed Agent chat streams, queues, and tool paths"},
78
80
  {"version":"0.25.1","date":"2026-09-02","zh":"修复 Codex 幽灵任务与对话空白","en":"Fixed Codex ghost tasks and blank chats"},
79
81
  {"version":"0.25.0","date":"2026-09-01","zh":"Pi 原生接入 · 多 Agent 对话更可靠","en":"Native Pi support · more reliable agent chat"},
80
82
  {"version":"0.24.0","date":"2026-08-11","zh":"Codex 任务进度与 Goal 原生上线","en":"Native Codex task progress and Goals"},
81
83
  {"version":"0.23.0","date":"2026-08-09","zh":"Codex 托管对话正式上线","en":"Managed Codex chat is here"},
82
84
  {"version":"0.22.1","date":"2026-08-04","zh":"网页宽度与网站版本分开控制","en":"Separate page width and site-version controls"},
83
- {"version":"0.22.0","date":"2026-08-03","zh":"全屏设置 · 窗口与分屏管理更清晰","en":"Full-screen Settings · clearer window and pane management"},
84
- {"version":"0.21.0","date":"2026-08-03","zh":"网页预览:手机直连、电脑代理与静态目录","en":"Web preview: direct, computer proxy, and static folders"},
85
- {"version":"0.20.3","date":"2026-08-01","zh":"实时终端显示更稳定流畅","en":"More stable, smoother live terminals"}
85
+ {"version":"0.22.0","date":"2026-08-03","zh":"全屏设置 · 窗口与分屏管理更清晰","en":"Full-screen Settings · clearer window and pane management"}
86
86
  ]
87
87
  }