handmux 0.29.0 → 0.29.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/dist/connectors/claude/index.js +45 -3
- package/dist/hooks/handmux-notify.sh +12 -2
- package/dist/hooks/handmux-write.cjs +4 -1
- package/dist/package.json +13 -13
- package/dist/public/assets/{index-D0tDAqO-.js → index-2ghkmGbT.js} +95 -95
- package/dist/public/index.html +1 -1
- package/dist/src/agent-runtime/bridge.js +63 -1
- package/dist/src/agent-runtime/bridgeTypes.js +2 -0
- package/dist/src/agent-runtime/runtime.js +1 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +27 -3
- package/dist/src/agents/claudeNativeTail.js +19 -3
- package/dist/src/agents/processIdentity.js +10 -1
- package/dist/src/deviceAuth/http.js +9 -3
- package/dist/src/push.js +5 -1
- package/package.json +4 -4
package/dist/public/index.html
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
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-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-2ghkmGbT.js"></script>
|
|
52
52
|
<link rel="stylesheet" crossorigin href="/assets/index-BjnnFTLC.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BjnnFTLC.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
@@ -83,6 +83,9 @@ function channelKey(agentId, runId, name) {
|
|
|
83
83
|
function consumerKey(agentId, name) {
|
|
84
84
|
return `${agentId}\0${name}`;
|
|
85
85
|
}
|
|
86
|
+
// Retention sweep cadence. Reclaims are also attempted at construction (a restart is when the largest
|
|
87
|
+
// dead set is present), so this only bounds growth during a long-lived server.
|
|
88
|
+
const RECLAIM_INTERVAL_MS = 5 * 60_000;
|
|
86
89
|
function mergeLimits(overrides) {
|
|
87
90
|
const limits = { ...DEFAULT_BRIDGE_LIMITS, ...overrides };
|
|
88
91
|
for (const [name, value] of Object.entries(limits)) {
|
|
@@ -198,9 +201,11 @@ export class LocalAgentBridge {
|
|
|
198
201
|
#newRequestId;
|
|
199
202
|
#now;
|
|
200
203
|
#retryDelayMs;
|
|
204
|
+
#onReclaim;
|
|
201
205
|
#writeTail = Promise.resolve();
|
|
206
|
+
#reclaimTimer;
|
|
202
207
|
#closed = false;
|
|
203
|
-
constructor({ runs, adapterIds, store = new MemoryBridgeStateStore(), limits, newConnectionId = randomUUID, newRequestId = randomUUID, now = Date.now, retryDelayMs = 250, }) {
|
|
208
|
+
constructor({ runs, adapterIds, store = new MemoryBridgeStateStore(), limits, newConnectionId = randomUUID, newRequestId = randomUUID, now = Date.now, retryDelayMs = 250, onReclaim, }) {
|
|
204
209
|
if (!runs || !Array.isArray(adapterIds) || adapterIds.length === 0) {
|
|
205
210
|
throw new TypeError('LocalAgentBridge requires a run registry and static adapter ids');
|
|
206
211
|
}
|
|
@@ -216,6 +221,7 @@ export class LocalAgentBridge {
|
|
|
216
221
|
this.#newRequestId = newRequestId;
|
|
217
222
|
this.#now = now;
|
|
218
223
|
this.#retryDelayMs = Math.max(1, retryDelayMs);
|
|
224
|
+
this.#onReclaim = onReclaim;
|
|
219
225
|
const persisted = parsePersistedState(this.#store.load());
|
|
220
226
|
const spoolBytes = new Map();
|
|
221
227
|
for (const channel of persisted.channels) {
|
|
@@ -244,9 +250,15 @@ export class LocalAgentBridge {
|
|
|
244
250
|
pumping: false,
|
|
245
251
|
pumpAgain: false,
|
|
246
252
|
retryTimer: undefined,
|
|
253
|
+
touchedAt: this.#now(),
|
|
247
254
|
durableWaiters: [],
|
|
248
255
|
});
|
|
249
256
|
}
|
|
257
|
+
// A restart is the natural point to drop channels whose runs died before it: nothing can attach to
|
|
258
|
+
// that run again, so their undelivered events are unreachable either way.
|
|
259
|
+
this.reclaimChannels();
|
|
260
|
+
this.#reclaimTimer = setInterval(() => this.reclaimChannels(), RECLAIM_INTERVAL_MS);
|
|
261
|
+
this.#reclaimTimer.unref?.();
|
|
250
262
|
}
|
|
251
263
|
connect(run) {
|
|
252
264
|
this.#assertLive(run);
|
|
@@ -359,6 +371,10 @@ export class LocalAgentBridge {
|
|
|
359
371
|
if (this.#closed)
|
|
360
372
|
return;
|
|
361
373
|
this.#closed = true;
|
|
374
|
+
if (this.#reclaimTimer) {
|
|
375
|
+
clearInterval(this.#reclaimTimer);
|
|
376
|
+
this.#reclaimTimer = undefined;
|
|
377
|
+
}
|
|
362
378
|
for (const connection of this.#connections.values())
|
|
363
379
|
connection.close();
|
|
364
380
|
this.#connections.clear();
|
|
@@ -418,10 +434,12 @@ export class LocalAgentBridge {
|
|
|
418
434
|
pumping: false,
|
|
419
435
|
pumpAgain: false,
|
|
420
436
|
retryTimer: undefined,
|
|
437
|
+
touchedAt: this.#now(),
|
|
421
438
|
durableWaiters: [],
|
|
422
439
|
};
|
|
423
440
|
this.#channels.set(key, state);
|
|
424
441
|
}
|
|
442
|
+
state.touchedAt = this.#now();
|
|
425
443
|
return state;
|
|
426
444
|
}
|
|
427
445
|
async #withWrite(operation) {
|
|
@@ -442,6 +460,50 @@ export class LocalAgentBridge {
|
|
|
442
460
|
channels: [...this.#channels.values()].map((state) => structuredClone(state.persisted)),
|
|
443
461
|
});
|
|
444
462
|
}
|
|
463
|
+
// Drops channels that can never be consumed again, so a reconnecting or flapping attachment cannot grow
|
|
464
|
+
// the bridge state without bound. A channel of a live run is never touched — it may reconnect and its
|
|
465
|
+
// undelivered events are still reachable. Returns what was dropped so a caller can log it.
|
|
466
|
+
reclaimChannels() {
|
|
467
|
+
const now = this.#now();
|
|
468
|
+
const dead = (state) => !state.subscription
|
|
469
|
+
&& state.durableWaiters.length === 0 && state.live.length === 0
|
|
470
|
+
// The registry contract returns null for a run it no longer holds; treat any falsy result the same
|
|
471
|
+
// so a registry that omits the value cannot silently disable retention.
|
|
472
|
+
&& !this.#runs.resolve(state.persisted.run);
|
|
473
|
+
let channels = 0;
|
|
474
|
+
let droppedEvents = 0;
|
|
475
|
+
const drop = (key, state) => {
|
|
476
|
+
if (state.retryTimer)
|
|
477
|
+
clearTimeout(state.retryTimer);
|
|
478
|
+
droppedEvents += state.persisted.durable.length;
|
|
479
|
+
this.#channels.delete(key);
|
|
480
|
+
channels += 1;
|
|
481
|
+
};
|
|
482
|
+
for (const [key, state] of [...this.#channels]) {
|
|
483
|
+
if (!dead(state))
|
|
484
|
+
continue;
|
|
485
|
+
// Drained ⇒ nothing can be lost. Still holding events ⇒ wait out the retention window first, so a
|
|
486
|
+
// transient gap in run bookkeeping cannot drop a notification that is still deliverable.
|
|
487
|
+
if (state.persisted.durable.length > 0 && now - state.touchedAt < this.limits.channelRetentionMs)
|
|
488
|
+
continue;
|
|
489
|
+
drop(key, state);
|
|
490
|
+
}
|
|
491
|
+
// Hard ceiling: retention must bound memory even while every dead channel is inside its window.
|
|
492
|
+
if (this.#channels.size > this.limits.maxChannels) {
|
|
493
|
+
const oldest = [...this.#channels].filter(([, state]) => dead(state))
|
|
494
|
+
.sort((left, right) => left[1].touchedAt - right[1].touchedAt);
|
|
495
|
+
for (const [key, state] of oldest) {
|
|
496
|
+
if (this.#channels.size <= this.limits.maxChannels)
|
|
497
|
+
break;
|
|
498
|
+
drop(key, state);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (channels > 0) {
|
|
502
|
+
this.#save();
|
|
503
|
+
this.#onReclaim?.({ channels, droppedEvents });
|
|
504
|
+
}
|
|
505
|
+
return { channels, droppedEvents };
|
|
506
|
+
}
|
|
445
507
|
#nextSequence(state) {
|
|
446
508
|
if (state.persisted.highWatermark >= Number.MAX_SAFE_INTEGER) {
|
|
447
509
|
throw new BridgeContractError('Bridge channel sequence is exhausted');
|
|
@@ -219,6 +219,7 @@ export class AgentRuntime {
|
|
|
219
219
|
runs: this.runs,
|
|
220
220
|
adapterIds,
|
|
221
221
|
store: new FileBridgeStateStore(bridgeStateFile),
|
|
222
|
+
onReclaim: (result) => logger.info('Reclaimed Bridge channels for Agent runs that are no longer live', result),
|
|
222
223
|
...(newBridgeConnectionId === undefined ? {} : { newConnectionId: newBridgeConnectionId }),
|
|
223
224
|
}),
|
|
224
225
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
1
2
|
import { executablePath } from '../agents/processIdentity.js';
|
|
2
3
|
import { defaultRun, etimeToMs, normTty } from '../agents/scanUtils.js';
|
|
3
4
|
function pane(value) {
|
|
@@ -124,6 +125,30 @@ function foregroundLeaf(rows, tty, paneCommand) {
|
|
|
124
125
|
.sort((first, second) => second.elapsedMs - first.elapsedMs
|
|
125
126
|
|| depth(first) - depth(second) || second.pid - first.pid)[0] ?? null;
|
|
126
127
|
}
|
|
128
|
+
// Process start time, in a form that only depends on the process itself.
|
|
129
|
+
//
|
|
130
|
+
// `ps -o lstart` is a wall-clock derivation (boot time + start ticks). A host that steps the guest clock
|
|
131
|
+
// (WSL2 syncs from Hyper-V, moving the guest forward every few tens of seconds) therefore makes the same
|
|
132
|
+
// live process report a *different* start time after every step, which every identity comparison reads as
|
|
133
|
+
// "the process was replaced". Linux exposes the raw start tick instead, and macOS does not step its clock
|
|
134
|
+
// this way, so it keeps lstart. The Hook records the same value per platform — keep the two in step
|
|
135
|
+
// (server/hooks/handmux-notify.sh).
|
|
136
|
+
// Exported so the platform split can be unit-tested; callers use inspectForeground.
|
|
137
|
+
export async function processStartedAt(run, pid) {
|
|
138
|
+
if (process.platform === 'linux') {
|
|
139
|
+
try {
|
|
140
|
+
const stat = await fsp.readFile(`/proc/${pid}/stat`, 'utf8');
|
|
141
|
+
// comm may contain spaces and parentheses: start from the last ')' to reach field 3 (state).
|
|
142
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
|
|
143
|
+
const startTicks = Number(fields[19]); // field 22 of /proc/<pid>/stat
|
|
144
|
+
if (Number.isSafeInteger(startTicks) && startTicks >= 0)
|
|
145
|
+
return startTicks;
|
|
146
|
+
}
|
|
147
|
+
catch { /* fall back to the portable lstart read */ }
|
|
148
|
+
}
|
|
149
|
+
const parsed = Date.parse(String(await run('ps', ['-p', String(pid), '-o', 'lstart='])).trim());
|
|
150
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
151
|
+
}
|
|
127
152
|
export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
|
|
128
153
|
return {
|
|
129
154
|
async inspectForeground(pane) {
|
|
@@ -137,12 +162,11 @@ export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
|
|
|
137
162
|
const row = foregroundLeaf(foregroundRows(output), tty, pane.currentCommand);
|
|
138
163
|
if (!row)
|
|
139
164
|
return null;
|
|
140
|
-
const
|
|
141
|
-
const parsedStartedAt = Date.parse(rawStartedAt);
|
|
165
|
+
const startedAt = await processStartedAt(run, row.pid);
|
|
142
166
|
const executable = await executablePath(run, row.pid);
|
|
143
167
|
return {
|
|
144
168
|
pid: row.pid,
|
|
145
|
-
...(
|
|
169
|
+
...(startedAt === undefined ? {} : { startedAt }),
|
|
146
170
|
tty: pane.tty ?? `/dev/${tty}`,
|
|
147
171
|
...(executable ? { executable } : {}),
|
|
148
172
|
...(row.command ? { commandLine: row.command } : {}),
|
|
@@ -6,6 +6,16 @@ const record = (value) => value !== null && typeof value === 'object' && !Array.
|
|
|
6
6
|
const METADATA = new Set(['last-prompt', 'ai-title', 'mode', 'permission-mode', 'file-history-snapshot']);
|
|
7
7
|
const MAX_BYTES = 65536;
|
|
8
8
|
const MAX_SESSIONS = 128;
|
|
9
|
+
// Claude's registry records the process start in the same platform-shaped form we read: a raw procfs tick
|
|
10
|
+
// on Linux, an lstart string on macOS (see processStartedAt in tmuxRuntime.ts). Compare like with like —
|
|
11
|
+
// assuming a date string made Linux native status permanently unknown (`Date.parse('473349 UTC')` is NaN).
|
|
12
|
+
function sameProcessStart(recorded, observed) {
|
|
13
|
+
if (typeof recorded !== 'string' || observed === undefined)
|
|
14
|
+
return false;
|
|
15
|
+
return /^\d+$/.test(recorded)
|
|
16
|
+
? Number(recorded) === observed
|
|
17
|
+
: Date.parse(`${recorded} UTC`) === observed;
|
|
18
|
+
}
|
|
9
19
|
function interruption(rows, sessionId, promptId, after, now) {
|
|
10
20
|
let duration;
|
|
11
21
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
@@ -97,8 +107,8 @@ export class ClaudeNativeTailReader {
|
|
|
97
107
|
this.#statuses.delete(registry);
|
|
98
108
|
return unknown();
|
|
99
109
|
}
|
|
100
|
-
if (!row || row.pid !== process.pid || row.sessionId !== payload.session_id
|
|
101
|
-
||
|
|
110
|
+
if (!row || row.pid !== process.pid || row.sessionId !== payload.session_id
|
|
111
|
+
|| row.kind !== 'interactive' || !sameProcessStart(row.procStart, process.startedAt))
|
|
102
112
|
return unknown();
|
|
103
113
|
this.#nativeProcesses.add(identity);
|
|
104
114
|
while (this.#nativeProcesses.size > MAX_SESSIONS)
|
|
@@ -114,7 +124,13 @@ export class ClaudeNativeTailReader {
|
|
|
114
124
|
if (typeof row.statusUpdatedAt === 'number' && row.statusUpdatedAt <= after) {
|
|
115
125
|
return { ...native, settled: null, localCommand: null };
|
|
116
126
|
}
|
|
117
|
-
|
|
127
|
+
// The status is only bounded from above here. A "newer than the process start" lower bound would need
|
|
128
|
+
// both sides in the same clock domain, which only holds where the start value is epoch-shaped (macOS);
|
|
129
|
+
// on Linux it is a raw tick. pid + session + start token already identify the row.
|
|
130
|
+
const startedEpoch = typeof process.startedAt === 'number' && process.startedAt > 1e12
|
|
131
|
+
? process.startedAt : undefined;
|
|
132
|
+
if (typeof row.statusUpdatedAt !== 'number' || row.statusUpdatedAt > now
|
|
133
|
+
|| (startedEpoch !== undefined && row.statusUpdatedAt < startedEpoch)
|
|
118
134
|
|| (row.status === 'idle' && (!native.transcriptComplete || row.statusUpdatedAt <= after || row.statusUpdatedAt < (native.latestNativeAt ?? 0)))
|
|
119
135
|
|| !['idle', 'busy', 'waiting'].includes(String(row.status)))
|
|
120
136
|
return unknown();
|
|
@@ -5,7 +5,13 @@ const SUCCESS_TTL_MS = 30_000;
|
|
|
5
5
|
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
|
+
// Linux appends " (deleted)" once the file is unlinked while the image stays mapped: Claude's auto-updater
|
|
9
|
+
// prunes the old version file under a running session, and that session keeps working. The marker must not
|
|
10
|
+
// reach a basename comparison — it would turn an unambiguous identity into a mismatch and silently drop
|
|
11
|
+
// the pane until Claude restarts. Every consumer reads the path through here, so normalize at the exit.
|
|
12
|
+
const DELETED_MARKER_RE = / \(deleted\)$/;
|
|
13
|
+
export const stripDeletedMarker = (file) => file.replace(DELETED_MARKER_RE, '');
|
|
14
|
+
async function rawExecutablePath(run, pid) {
|
|
9
15
|
if (process.platform === 'linux') {
|
|
10
16
|
try {
|
|
11
17
|
return await fsp.readlink(`/proc/${pid}/exe`);
|
|
@@ -28,6 +34,9 @@ export async function executablePath(run, pid) {
|
|
|
28
34
|
}
|
|
29
35
|
return '';
|
|
30
36
|
}
|
|
37
|
+
export async function executablePath(run, pid) {
|
|
38
|
+
return stripDeletedMarker(await rawExecutablePath(run, pid));
|
|
39
|
+
}
|
|
31
40
|
function parseForegroundProcesses(out) {
|
|
32
41
|
const rows = [];
|
|
33
42
|
for (const line of String(out).split('\n')) {
|
|
@@ -14,7 +14,13 @@ function browserSummary(ua) {
|
|
|
14
14
|
const os = /(?:iPhone|iPad|iPod)/.test(ua) ? 'iOS' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
|
|
15
15
|
return os ? `${browser} · ${os}` : browser;
|
|
16
16
|
}
|
|
17
|
-
export function createDeviceAuthRouter({ service, resolveOrigin, resolvePublicUrl, previewDomain }) {
|
|
17
|
+
export function createDeviceAuthRouter({ service, resolveOrigin, resolvePublicUrl, previewDomain, buckets: bucketOptions }) {
|
|
18
|
+
const maxRequests = bucketOptions?.maxRequests ?? 600;
|
|
19
|
+
const maxCreates = bucketOptions?.maxCreates ?? 20;
|
|
20
|
+
const windowMs = bucketOptions?.windowMs ?? 60_000;
|
|
21
|
+
if (![maxRequests, maxCreates, windowMs].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
|
22
|
+
throw new TypeError('Device auth bucket limits must be positive integers');
|
|
23
|
+
}
|
|
18
24
|
const router = Router();
|
|
19
25
|
const advertisedOrigin = () => {
|
|
20
26
|
const value = resolvePublicUrl?.();
|
|
@@ -84,7 +90,7 @@ export function createDeviceAuthRouter({ service, resolveOrigin, resolvePublicUr
|
|
|
84
90
|
const token = principal && service.isTokenPrincipal(principal);
|
|
85
91
|
const buckets = !principal ? anonymousBuckets : token ? tokenBuckets : authenticatedBuckets;
|
|
86
92
|
for (const [key, value] of buckets)
|
|
87
|
-
if (now - value.at >=
|
|
93
|
+
if (now - value.at >= windowMs)
|
|
88
94
|
buckets.delete(key);
|
|
89
95
|
const key = !principal ? req.socket.remoteAddress ?? 'unknown'
|
|
90
96
|
: token ? `${principal.sessionId}:${req.socket.remoteAddress ?? 'unknown'}:${(req.get('user-agent') ?? '').slice(0, 200)}`
|
|
@@ -101,7 +107,7 @@ export function createDeviceAuthRouter({ service, resolveOrigin, resolvePublicUr
|
|
|
101
107
|
bucket.requests++;
|
|
102
108
|
if (req.method === 'POST' && req.path === '/pairing')
|
|
103
109
|
bucket.creates++;
|
|
104
|
-
if (bucket.requests >
|
|
110
|
+
if (bucket.requests > maxRequests || bucket.creates > maxCreates) {
|
|
105
111
|
res.set('Retry-After', '60').status(429).json({ error: 'AUTH_RATE_LIMIT', message: 'Wait one minute and try again' });
|
|
106
112
|
return;
|
|
107
113
|
}
|
package/dist/src/push.js
CHANGED
|
@@ -75,7 +75,11 @@ let subs = load();
|
|
|
75
75
|
let deviceAuthorization = null;
|
|
76
76
|
const allowed = (rec) => {
|
|
77
77
|
try {
|
|
78
|
-
|
|
78
|
+
// Once device authorization is active, legacy unbound subscriptions are no
|
|
79
|
+
// longer eligible. They have no device identity that can be authorized.
|
|
80
|
+
return deviceAuthorization
|
|
81
|
+
? Boolean(rec.authDeviceId && deviceAuthorization(rec.authDeviceId))
|
|
82
|
+
: !rec.authDeviceId;
|
|
79
83
|
}
|
|
80
84
|
catch {
|
|
81
85
|
return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handmux",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.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.29.2","date":"2026-09-18","zh":"修复 Linux 下 Claude 事件上报的兼容问题","en":"Compatibility fix for Claude event reporting on Linux"},
|
|
79
|
+
{"version":"0.29.1","date":"2026-09-18","zh":"修复 Claude 对话入口丢失 · 长时间运行不再变卡","en":"Fix lost Claude chat entries · no slowdown after long runs"},
|
|
78
80
|
{"version":"0.29.0","date":"2026-09-16","zh":"可信设备与可信地址保护","en":"Trusted device and address protection"},
|
|
79
81
|
{"version":"0.28.0","date":"2026-09-11","zh":"Claude 对话更可靠 · 工具图片预览","en":"More reliable Claude chat · tool image previews"},
|
|
80
82
|
{"version":"0.27.2","date":"2026-09-08","zh":"修复多标签页偶发鉴权弹窗","en":"Fix intermittent authentication prompts across tabs"},
|
|
81
83
|
{"version":"0.27.1","date":"2026-09-08","zh":"腾讯语音 · 终端光标修复 · Codex 对话恢复","en":"Tencent voice · terminal cursor fixes · Codex chat recovery"},
|
|
82
84
|
{"version":"0.27.0","date":"2026-09-05","zh":"腾讯语音输入 · 终端输入更准确","en":"Tencent voice input · more accurate terminal typing"},
|
|
83
|
-
{"version":"0.26.0","date":"2026-09-04","zh":"对话精细复制 · Pi 上下文状态","en":"Precise chat copy · Pi context status"}
|
|
84
|
-
{"version":"0.25.2","date":"2026-09-02","zh":"修复 Agent 对话断流、排队与工具路径","en":"Fixed Agent chat streams, queues, and tool paths"},
|
|
85
|
-
{"version":"0.25.1","date":"2026-09-02","zh":"修复 Codex 幽灵任务与对话空白","en":"Fixed Codex ghost tasks and blank chats"}
|
|
85
|
+
{"version":"0.26.0","date":"2026-09-04","zh":"对话精细复制 · Pi 上下文状态","en":"Precise chat copy · Pi context status"}
|
|
86
86
|
]
|
|
87
87
|
}
|