handmux 0.27.2 → 0.29.1
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 +122 -25
- package/dist/hooks/handmux-notify.sh +14 -2
- package/dist/hooks/handmux-write.cjs +15 -1
- package/dist/package.json +19 -19
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-aBgNeovq.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridge.js +63 -1
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/bridgeTypes.js +2 -0
- 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/agent-runtime/runtime.js +1 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +27 -3
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +287 -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 +23 -7
- 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 +327 -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 +48 -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 +5 -5
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- package/dist/public/assets/index-Cdrh2CUp.css +0 -32
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-aBgNeovq.js"></script>
|
|
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>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -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');
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DEFAULT_BRIDGE_LIMITS } from './bridgeTypes.js';
|
|
6
|
+
import { AgentRunError } from './run.js';
|
|
6
7
|
const PROTOCOL_VERSION = 1;
|
|
7
8
|
const NAME_RE = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
8
9
|
const MAX_HANDSHAKE_BYTES = 64 * 1024;
|
|
@@ -46,8 +47,6 @@ function wirePeer(socket, maxBytes, onMessage) {
|
|
|
46
47
|
socket.write(encoded);
|
|
47
48
|
},
|
|
48
49
|
close() {
|
|
49
|
-
if (peer.closed)
|
|
50
|
-
return;
|
|
51
50
|
peer.closed = true;
|
|
52
51
|
socket.destroy();
|
|
53
52
|
},
|
|
@@ -251,12 +250,28 @@ export class LocalAgentBridgeTransportServer {
|
|
|
251
250
|
const peer = wirePeer(socket, MAX_HANDSHAKE_BYTES, (value) => {
|
|
252
251
|
const operation = session.tail.then(() => this.#message(session, nonce, value));
|
|
253
252
|
session.tail = operation.catch((error) => {
|
|
253
|
+
if (!session.ready) {
|
|
254
|
+
if (!session.authenticated || peer.closed) {
|
|
255
|
+
this.#closeSession(session);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Flush the rejection before closing so old clients still observe EOF and new clients get
|
|
259
|
+
// the actual cause. Only a validated, authenticated hello may receive diagnostics.
|
|
260
|
+
try {
|
|
261
|
+
peer.send({ type: 'handshake-error', error: errorMessage(error),
|
|
262
|
+
...(error instanceof AgentRunError ? { code: error.code } : {}) });
|
|
263
|
+
peer.closed = true;
|
|
264
|
+
peer.socket.end(() => this.#closeSession(session));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
this.#closeSession(session);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
254
271
|
try {
|
|
255
272
|
failure(peer, record(value)?.id, error);
|
|
256
273
|
}
|
|
257
274
|
catch { /* closing */ }
|
|
258
|
-
if (!session.connection)
|
|
259
|
-
this.#closeSession(session);
|
|
260
275
|
});
|
|
261
276
|
});
|
|
262
277
|
const handshakeTimer = setTimeout(() => this.#closeSession(session), this.#handshakeTimeoutMs);
|
|
@@ -270,6 +285,8 @@ export class LocalAgentBridgeTransportServer {
|
|
|
270
285
|
peer.send({ type: 'challenge', protocolVersion: PROTOCOL_VERSION, nonce });
|
|
271
286
|
}
|
|
272
287
|
async #message(session, nonce, raw) {
|
|
288
|
+
if (session.peer.closed)
|
|
289
|
+
return;
|
|
273
290
|
const value = record(raw);
|
|
274
291
|
if (!value || typeof value.type !== 'string')
|
|
275
292
|
throw new Error('Invalid Bridge transport frame');
|
|
@@ -285,6 +302,7 @@ export class LocalAgentBridgeTransportServer {
|
|
|
285
302
|
|| (!replacing && value.generationId !== undefined)) {
|
|
286
303
|
throw new Error('Invalid Bridge transport generation');
|
|
287
304
|
}
|
|
305
|
+
session.authenticated = true;
|
|
288
306
|
const lease = await this.#authorize(value.adapterId, structuredClone(value.candidate), replacing ? { id: value.generationId, replace: true } : undefined);
|
|
289
307
|
if (lease.ref.agentId !== value.adapterId)
|
|
290
308
|
throw new Error('Bridge transport authorization mismatch');
|
|
@@ -302,6 +320,7 @@ export class LocalAgentBridgeTransportServer {
|
|
|
302
320
|
clearTimeout(session.handshakeTimer);
|
|
303
321
|
session.handshakeTimer = undefined;
|
|
304
322
|
session.peer.maxBytes = Math.max(session.connection.limits.maxFrameBytes, session.connection.limits.maxSnapshotBytes) + WIRE_OVERHEAD_BYTES;
|
|
323
|
+
session.ready = true;
|
|
305
324
|
session.peer.send({
|
|
306
325
|
type: 'ready', protocolVersion: PROTOCOL_VERSION,
|
|
307
326
|
connectionId: session.connection.connectionId,
|
|
@@ -483,6 +502,14 @@ export async function connectBridgeTransport({ socketPath, authToken, adapterId,
|
|
|
483
502
|
});
|
|
484
503
|
return;
|
|
485
504
|
}
|
|
505
|
+
if (value.type === 'handshake-error' && !settled) {
|
|
506
|
+
const error = new Error(bounded(value.error, 4096) ? value.error : 'Bridge handshake rejected');
|
|
507
|
+
if (bounded(value.code, 128))
|
|
508
|
+
Object.assign(error, { code: value.code });
|
|
509
|
+
finish({ error });
|
|
510
|
+
peer.close();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
486
513
|
if (value.type === 'ready') {
|
|
487
514
|
finish({ value });
|
|
488
515
|
return;
|
|
@@ -20,13 +20,16 @@ import { AgentRuntime, } from './runtime.js';
|
|
|
20
20
|
export function createClaudeConversationActivityReader(events) {
|
|
21
21
|
return {
|
|
22
22
|
async read(run) {
|
|
23
|
-
|
|
23
|
+
if (events?.paneSession && events.paneSession(run.ref.paneId)?.sessionId !== run.ref.sessionId) {
|
|
24
|
+
return { activity: 'unknown', activeTurn: { state: 'unknown' } };
|
|
25
|
+
}
|
|
26
|
+
const kind = events?.paneKind?.(run.ref.paneId, run.process) ?? null;
|
|
24
27
|
const activity = kind === 'working' ? 'working'
|
|
25
28
|
: kind === 'permission' ? 'waiting'
|
|
26
29
|
: kind === 'compacting' ? 'compacting'
|
|
27
30
|
: kind === 'done' || kind === 'idle' || kind === 'end' || kind === 'error'
|
|
28
31
|
? 'idle' : 'unknown';
|
|
29
|
-
const completionToken = events?.paneCompletionToken?.(run.ref.paneId) ?? null;
|
|
32
|
+
const completionToken = events?.paneCompletionToken?.(run.ref.paneId, run.process) ?? null;
|
|
30
33
|
return {
|
|
31
34
|
activity,
|
|
32
35
|
activeTurn: activity === 'idle' ? { state: 'none' }
|
|
@@ -89,6 +92,11 @@ export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRo
|
|
|
89
92
|
agentId: 'claude',
|
|
90
93
|
sourceId: 'claude.hooks',
|
|
91
94
|
label: 'Claude',
|
|
95
|
+
sourceEventSequence: (eventId) => {
|
|
96
|
+
const match = /^claude-hook-([1-9]\d*)$/.exec(eventId);
|
|
97
|
+
const sequence = match ? Number(match[1]) : NaN;
|
|
98
|
+
return Number.isSafeInteger(sequence) ? sequence : null;
|
|
99
|
+
},
|
|
92
100
|
});
|
|
93
101
|
const conversationSessions = claudeEvents && typeof claudeEvents.paneSession === 'function'
|
|
94
102
|
? claudeEvents
|
|
@@ -100,7 +100,7 @@ function normalizeInterrupt(raw) {
|
|
|
100
100
|
}
|
|
101
101
|
const PUBLIC_REASONS = new Set([
|
|
102
102
|
'invalid_request', 'unsupported', 'stale_run', 'conflict',
|
|
103
|
-
'provider_rejected', 'temporarily_unavailable', 'delivery_unconfirmed',
|
|
103
|
+
'provider_rejected', 'terminal_draft_conflict', 'temporarily_unavailable', 'delivery_unconfirmed',
|
|
104
104
|
]);
|
|
105
105
|
const LEGACY_REASON_MAP = Object.freeze({
|
|
106
106
|
stale_lease: 'stale_run',
|
|
@@ -1648,7 +1648,7 @@ export class ConversationService {
|
|
|
1648
1648
|
delete detached.steerAnchor;
|
|
1649
1649
|
delete detached.steerDispatchPlan;
|
|
1650
1650
|
if (receipt.outcome === 'rejected')
|
|
1651
|
-
detached.autoDispatchBlockedReason = 'provider_rejected';
|
|
1651
|
+
detached.autoDispatchBlockedReason = receipt.reason === 'terminal_draft_conflict' ? 'terminal_draft_conflict' : 'provider_rejected';
|
|
1652
1652
|
else
|
|
1653
1653
|
delete detached.autoDispatchBlockedReason;
|
|
1654
1654
|
detached.revision = ++this.#state.ledgerRevision;
|
|
@@ -1755,7 +1755,7 @@ export class ConversationService {
|
|
|
1755
1755
|
current.state = 'queued';
|
|
1756
1756
|
delete current.dispatchOrigin;
|
|
1757
1757
|
if (receipt.outcome === 'rejected') {
|
|
1758
|
-
current.autoDispatchBlockedReason = 'provider_rejected';
|
|
1758
|
+
current.autoDispatchBlockedReason = receipt.reason === 'terminal_draft_conflict' ? 'terminal_draft_conflict' : 'provider_rejected';
|
|
1759
1759
|
}
|
|
1760
1760
|
else
|
|
1761
1761
|
delete current.autoDispatchBlockedReason;
|
|
@@ -32,7 +32,7 @@ function validBaseline(value) {
|
|
|
32
32
|
}
|
|
33
33
|
const SEND_STATUSES = new Set(['accepted', 'queued', 'rejected', 'unknown']);
|
|
34
34
|
const SEND_REASONS = new Set([
|
|
35
|
-
'invalid_request', 'unsupported', 'stale_run', 'conflict', 'provider_rejected',
|
|
35
|
+
'invalid_request', 'unsupported', 'stale_run', 'conflict', 'provider_rejected', 'terminal_draft_conflict',
|
|
36
36
|
'temporarily_unavailable', 'delivery_unconfirmed',
|
|
37
37
|
]);
|
|
38
38
|
function validLegacyReceipt(value) {
|
|
@@ -120,7 +120,7 @@ export function parseConversationState(value) {
|
|
|
120
120
|
throw new Error('Corrupt Conversation queue order');
|
|
121
121
|
}
|
|
122
122
|
if (row.autoDispatchBlockedReason !== undefined
|
|
123
|
-
&& row.autoDispatchBlockedReason !== 'provider_rejected') {
|
|
123
|
+
&& row.autoDispatchBlockedReason !== 'provider_rejected' && row.autoDispatchBlockedReason !== 'terminal_draft_conflict') {
|
|
124
124
|
throw new Error('Corrupt Conversation dispatch barrier');
|
|
125
125
|
}
|
|
126
126
|
if (row.autoDispatchBlockedReason !== undefined && state !== 'queued') {
|
|
@@ -834,7 +834,9 @@ export class InboxService {
|
|
|
834
834
|
async #submitTerminalReplay(agentId, replay) {
|
|
835
835
|
const receivedAt = this.#now();
|
|
836
836
|
if (!isRecord(replay) || !validRunRef(replay.run) || replay.run.agentId !== agentId
|
|
837
|
-
||
|
|
837
|
+
|| typeof replay.eventId !== 'string' || !EVENT_ID_RE.test(replay.eventId)
|
|
838
|
+
|| (replay.historyOnly !== undefined && typeof replay.historyOnly !== 'boolean')
|
|
839
|
+
|| this.#runs.status(replay.run) !== (replay.historyOnly ? 'current' : 'revoked')
|
|
838
840
|
|| (replay.state !== 'done' && replay.state !== 'error')) {
|
|
839
841
|
return this.#result(receivedAt, false, { reason: 'invalid_operation' });
|
|
840
842
|
}
|
|
@@ -852,7 +854,12 @@ export class InboxService {
|
|
|
852
854
|
return this.#result(receivedAt, false, { reason: 'invalid_operation' });
|
|
853
855
|
}
|
|
854
856
|
const operation = normalized.operation;
|
|
857
|
+
const currentLease = replay.historyOnly ? this.#runs.resolve(replay.run) : undefined;
|
|
855
858
|
return this.#withWrite(async () => {
|
|
859
|
+
if (replay.historyOnly && (!currentLease || currentLease.signal.aborted
|
|
860
|
+
|| this.#runs.resolve(replay.run) !== currentLease)) {
|
|
861
|
+
return this.#result(receivedAt, false, { reason: 'stale_lease' });
|
|
862
|
+
}
|
|
856
863
|
const before = structuredClone(this.#state);
|
|
857
864
|
const run = this.#ensureRun(replay.run);
|
|
858
865
|
const sourceDuplicate = this.#sourceReceipt(run, operation.source);
|
|
@@ -906,7 +913,12 @@ export class InboxService {
|
|
|
906
913
|
}
|
|
907
914
|
try {
|
|
908
915
|
const inboxSequence = this.#nextSequence(run);
|
|
909
|
-
const
|
|
916
|
+
const historicalEvents = replay.historyOnly ? this.#crossRunEventReceipts(replay.run, operation.eventId) : [];
|
|
917
|
+
if (historicalEvents.some((receipt) => receipt.operationHash !== normalized.hash)) {
|
|
918
|
+
throw new InboxContractError('Historical terminal event payload changed');
|
|
919
|
+
}
|
|
920
|
+
const acceptedAt = historicalEvents.length
|
|
921
|
+
? Math.min(...historicalEvents.map((receipt) => receipt.acceptedAt)) : receivedAt;
|
|
910
922
|
run.events.push({
|
|
911
923
|
eventId: operation.eventId,
|
|
912
924
|
operationHash: normalized.hash,
|
|
@@ -919,6 +931,12 @@ export class InboxService {
|
|
|
919
931
|
acceptedAt,
|
|
920
932
|
...(operation.eventId === undefined ? {} : { eventId: operation.eventId }),
|
|
921
933
|
});
|
|
934
|
+
if (replay.historyOnly || historicalEvents.length) {
|
|
935
|
+
this.#pruneExpired(receivedAt);
|
|
936
|
+
this.#save();
|
|
937
|
+
this.#revision += 1;
|
|
938
|
+
return this.#result(receivedAt, true, { inboxSequence, acceptedAt });
|
|
939
|
+
}
|
|
922
940
|
const terminal = this.#terminalNotification(replay.run, {
|
|
923
941
|
state: operation.state,
|
|
924
942
|
...(operation.eventId === undefined ? {} : { eventId: operation.eventId }),
|
|
@@ -159,12 +159,13 @@ export class AgentRunRuntime {
|
|
|
159
159
|
}
|
|
160
160
|
async #verify(verify, candidate) {
|
|
161
161
|
let accepted = false;
|
|
162
|
+
let timedOut = false;
|
|
162
163
|
let timer;
|
|
163
164
|
try {
|
|
164
165
|
accepted = await Promise.race([
|
|
165
166
|
Promise.resolve(verify(candidate)).then(Boolean, () => false),
|
|
166
167
|
new Promise((resolve) => {
|
|
167
|
-
timer = setTimeout(() => resolve(false), this.#verifyTimeoutMs);
|
|
168
|
+
timer = setTimeout(() => { timedOut = true; resolve(false); }, this.#verifyTimeoutMs);
|
|
168
169
|
}),
|
|
169
170
|
]);
|
|
170
171
|
}
|
|
@@ -173,6 +174,9 @@ export class AgentRunRuntime {
|
|
|
173
174
|
if (timer)
|
|
174
175
|
clearTimeout(timer);
|
|
175
176
|
}
|
|
177
|
+
if (timedOut) {
|
|
178
|
+
throw new AgentRunError('attachment-verification-timeout', `Agent process verification timed out after ${this.#verifyTimeoutMs} ms; check slow process queries and retry`);
|
|
179
|
+
}
|
|
176
180
|
if (!accepted) {
|
|
177
181
|
throw new AgentRunError('attachment-unverified', 'Agent attachment no longer matches the live process');
|
|
178
182
|
}
|
|
@@ -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 } : {}),
|
|
@@ -79,8 +79,9 @@ export async function findClaudeSessionFile(root, sessionId) {
|
|
|
79
79
|
}
|
|
80
80
|
return matches[0] ?? null;
|
|
81
81
|
}
|
|
82
|
-
function sourceViewId(sessionId) {
|
|
83
|
-
|
|
82
|
+
function sourceViewId(sessionId, reordered) {
|
|
83
|
+
const suffix = reordered.length ? `:${createHash('sha256').update(JSON.stringify(reordered)).digest('hex')}` : '';
|
|
84
|
+
return `claude-session:${sessionId}${suffix}`;
|
|
84
85
|
}
|
|
85
86
|
function sourceTime(value) {
|
|
86
87
|
if (typeof value !== 'string')
|
|
@@ -167,11 +168,28 @@ function toolItems(message, sessionId, id, tool) {
|
|
|
167
168
|
}
|
|
168
169
|
return items;
|
|
169
170
|
}
|
|
171
|
+
// Read only the provider's human-facing scalar fields. This is not an XML document loader:
|
|
172
|
+
// nested markup, output-file paths, internal IDs and external entities are never interpreted.
|
|
173
|
+
function taskNotificationText(value) {
|
|
174
|
+
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
175
|
+
const field = (name) => {
|
|
176
|
+
const text = new RegExp(`<${name}>([^<>]*)</${name}>`).exec(value)?.[1] ?? '';
|
|
177
|
+
return text.replace(/&(amp|lt|gt|quot|apos);/g, (_match, entity) => entities[entity])
|
|
178
|
+
.trim();
|
|
179
|
+
};
|
|
180
|
+
const summary = field('summary') || 'Background task update';
|
|
181
|
+
const details = [...new Set([field('status'), field('event')].filter(Boolean))];
|
|
182
|
+
return clipped(`${summary}${details.length ? ` (${details.join(', ')})` : ''}`, 4096).text;
|
|
183
|
+
}
|
|
170
184
|
function projectMessage(message, sessionId, ordinal) {
|
|
171
185
|
const id = `claude:${message.i}:${ordinal}:${message.type}`;
|
|
172
186
|
if (message.type === 'thinking')
|
|
173
187
|
return [];
|
|
174
188
|
if (message.type === 'text' && message.role && typeof message.text === 'string' && message.text) {
|
|
189
|
+
if (message.source === 'task-notification') {
|
|
190
|
+
return [{ ...itemBase(message, sessionId, id), kind: 'notice', level: 'info',
|
|
191
|
+
code: 'background_task', message: taskNotificationText(message.text) }];
|
|
192
|
+
}
|
|
175
193
|
return [textItem(message, sessionId, id, message.role, message.text)];
|
|
176
194
|
}
|
|
177
195
|
if (message.type === 'tool' && message.tool)
|
|
@@ -196,6 +214,11 @@ function projectMessage(message, sessionId, ordinal) {
|
|
|
196
214
|
}
|
|
197
215
|
if (message.type === 'slash') {
|
|
198
216
|
const command = `${message.name ?? '/command'}${message.args ? ` ${message.args}` : ''}`;
|
|
217
|
+
// /compact starts as the user's submitted message. Keep that role when its canonical scaffold
|
|
218
|
+
// arrives so the canonical user occurrence replaces the local bubble; the result stays separate.
|
|
219
|
+
if (message.name?.toLowerCase() === '/compact') {
|
|
220
|
+
return [textItem(message, sessionId, id, 'user', command)];
|
|
221
|
+
}
|
|
199
222
|
return [{
|
|
200
223
|
...itemBase(message, sessionId, id), kind: 'notice', level: 'info',
|
|
201
224
|
code: 'slash_command', message: clipped(command, 4096).text,
|
|
@@ -203,8 +226,36 @@ function projectMessage(message, sessionId, ordinal) {
|
|
|
203
226
|
}
|
|
204
227
|
return [];
|
|
205
228
|
}
|
|
229
|
+
function normalizedOrder(messages) {
|
|
230
|
+
// Assign IDs in native append order, before normalizing display order. A late command must not
|
|
231
|
+
// rename a summary already shown to a client (or any of the following records).
|
|
232
|
+
const groups = messages.map((message, ordinal) => ({ message, ordinal }));
|
|
233
|
+
const reordered = [];
|
|
234
|
+
for (let index = 1; index < groups.length; index++) {
|
|
235
|
+
const current = groups[index];
|
|
236
|
+
const previous = groups[index - 1];
|
|
237
|
+
if (current.message.type !== 'slash' || current.message.name?.toLowerCase() !== '/compact'
|
|
238
|
+
|| previous.message.type !== 'compact')
|
|
239
|
+
continue;
|
|
240
|
+
const commandTime = sourceTime(current.message.ts);
|
|
241
|
+
const summaryTime = sourceTime(previous.message.ts);
|
|
242
|
+
if (commandTime === undefined || summaryTime === undefined || commandTime >= summaryTime)
|
|
243
|
+
continue;
|
|
244
|
+
// Claude may append the command scaffold after its summary, retaining the earlier command time.
|
|
245
|
+
// Only this adjacent, proven inversion is normalized; ordinary history is never time-sorted.
|
|
246
|
+
groups[index - 1] = current;
|
|
247
|
+
groups[index] = previous;
|
|
248
|
+
reordered.push(`claude:${current.message.i}:${current.ordinal}:${current.message.type}`);
|
|
249
|
+
index++;
|
|
250
|
+
}
|
|
251
|
+
return { groups, reordered };
|
|
252
|
+
}
|
|
206
253
|
function project(messages, sessionId) {
|
|
207
|
-
|
|
254
|
+
const { groups, reordered } = normalizedOrder(messages);
|
|
255
|
+
return {
|
|
256
|
+
items: groups.flatMap(({ message, ordinal }) => projectMessage(message, sessionId, ordinal)),
|
|
257
|
+
viewId: sourceViewId(sessionId, reordered),
|
|
258
|
+
};
|
|
208
259
|
}
|
|
209
260
|
export function createClaudeConversationAdapter({ projectsRoot = projectsDir(), sessions = null, reader = transcriptReader, findSessionFile = findClaudeSessionFile, control, livePollMs = 750, } = {}) {
|
|
210
261
|
if (!path.isAbsolute(projectsRoot)) {
|
|
@@ -242,7 +293,7 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
|
|
|
242
293
|
async function readPage(session, request) {
|
|
243
294
|
const file = await fileForSession(session.sessionId);
|
|
244
295
|
const parsed = file ? await reader.read(file, createTranscriptParser) : [];
|
|
245
|
-
const all = project(parsed, session.sessionId);
|
|
296
|
+
const { items: all, viewId } = project(parsed, session.sessionId);
|
|
246
297
|
let end = all.length;
|
|
247
298
|
if (request.beforeSourceCursor !== undefined) {
|
|
248
299
|
if (!/^\d+$/.test(request.beforeSourceCursor))
|
|
@@ -255,7 +306,7 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
|
|
|
255
306
|
const start = Math.max(0, end - request.limit);
|
|
256
307
|
return {
|
|
257
308
|
sessionId: session.sessionId,
|
|
258
|
-
sourceViewId:
|
|
309
|
+
sourceViewId: viewId,
|
|
259
310
|
sourceHistoryToken: createHash('sha256').update(JSON.stringify(all)).digest('hex'),
|
|
260
311
|
items: all.slice(start, end),
|
|
261
312
|
...(start > 0 ? { previousSourceCursor: String(start) } : {}),
|
|
@@ -271,10 +322,13 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
|
|
|
271
322
|
if (!file)
|
|
272
323
|
return null;
|
|
273
324
|
boundFiles.set(target.sessionId, file);
|
|
325
|
+
// Discovery needs only the order epoch, not a second projection/hash of every tool result.
|
|
326
|
+
const parsed = await reader.read(file, createTranscriptParser);
|
|
327
|
+
const viewId = sourceViewId(target.sessionId, normalizedOrder(parsed).reordered);
|
|
274
328
|
return {
|
|
275
329
|
session: { agentId: 'claude', sessionId: target.sessionId },
|
|
276
330
|
...(isRun(target) ? { run: target } : {}),
|
|
277
|
-
sourceViewId:
|
|
331
|
+
sourceViewId: viewId,
|
|
278
332
|
capabilities: isRun(target) && control ? {
|
|
279
333
|
history: true, live: 'settled', sendable: true, send: ['prompt'], interrupt: true,
|
|
280
334
|
} : { history: true, live: 'poll' },
|
|
@@ -307,6 +361,12 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
|
|
|
307
361
|
if (closed || run.signal.aborted)
|
|
308
362
|
return;
|
|
309
363
|
const next = await readPage(session, { limit: 1 });
|
|
364
|
+
if (next.sourceViewId !== baseline.sourceViewId) {
|
|
365
|
+
close();
|
|
366
|
+
await sink({ type: 'stream.gap', sourceSequence: ++sourceSequence,
|
|
367
|
+
afterSourceSequence: sourceSequence - 1 });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
310
370
|
if (next.sourceHistoryToken !== historyToken) {
|
|
311
371
|
historyToken = next.sourceHistoryToken;
|
|
312
372
|
await sink({
|
|
@@ -345,6 +405,9 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
|
|
|
345
405
|
: control.sendPrompt(run.ref.paneId, request.text));
|
|
346
406
|
if (result && typeof result === 'object'
|
|
347
407
|
&& result.nativeMutation === false) {
|
|
408
|
+
if (result.reason === 'terminal_draft_conflict') {
|
|
409
|
+
return { outcome: 'rejected', nativeMutation: false, reason: 'terminal_draft_conflict' };
|
|
410
|
+
}
|
|
348
411
|
return { outcome: 'busy', nativeMutation: false };
|
|
349
412
|
}
|
|
350
413
|
return { outcome: 'accepted' };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isSessionUuid } from './scanUtils.js';
|
|
3
|
+
const METADATA = new Set(['last-prompt', 'ai-title', 'mode', 'permission-mode', 'file-history-snapshot']);
|
|
4
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null);
|
|
5
|
+
// undefined: no native evidence newer than the Hook. null: newer history is not a proven completion.
|
|
6
|
+
// Keeping those separate prevents falling back from a native completion token to an older Hook token
|
|
7
|
+
// while another user turn is being appended, which would look like an additional completion to Core.
|
|
8
|
+
export function claudeLocalCommandCompletion(payload, after, now, rows) {
|
|
9
|
+
const sessionId = payload.session_id;
|
|
10
|
+
const file = payload.transcript_path;
|
|
11
|
+
if (!isSessionUuid(sessionId) || typeof file !== 'string'
|
|
12
|
+
|| path.basename(file) !== `${sessionId}.jsonl`)
|
|
13
|
+
return undefined;
|
|
14
|
+
const native = [];
|
|
15
|
+
for (let index = rows.length - 1; index >= 0 && native.length < 2; index--) {
|
|
16
|
+
const item = rows[index];
|
|
17
|
+
if (!item)
|
|
18
|
+
return null;
|
|
19
|
+
if (typeof item.type === 'string' && METADATA.has(item.type))
|
|
20
|
+
continue;
|
|
21
|
+
const timestamp = typeof item.timestamp === 'string' ? Date.parse(item.timestamp) : NaN;
|
|
22
|
+
if (!Number.isFinite(timestamp))
|
|
23
|
+
return null;
|
|
24
|
+
if (timestamp <= after && !native.length)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (timestamp > now)
|
|
27
|
+
return null;
|
|
28
|
+
native.unshift(item);
|
|
29
|
+
}
|
|
30
|
+
if (!native.length)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (native.length !== 2)
|
|
33
|
+
return null;
|
|
34
|
+
const [command, output] = native;
|
|
35
|
+
for (const item of native) {
|
|
36
|
+
if (item.type !== 'user' || item.sessionId !== sessionId || item.isMeta === true
|
|
37
|
+
|| item.isCompactSummary === true || item.isSidechain === true || !isSessionUuid(item.uuid)
|
|
38
|
+
|| record(item.message)?.role !== 'user')
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const commandText = record(command.message)?.content;
|
|
42
|
+
const outputText = record(output.message)?.content;
|
|
43
|
+
if (typeof commandText !== 'string' || typeof outputText !== 'string'
|
|
44
|
+
|| !/^\s*<command-name>\/[a-z][\w-]*<\/command-name>(?:\s*<command-message>[^<>]*<\/command-message>)?(?:\s*<command-args>[^<>]*<\/command-args>)?\s*$/i.test(commandText)
|
|
45
|
+
|| !/^\s*<local-command-stdout>[\s\S]+<\/local-command-stdout>\s*$/.test(outputText)
|
|
46
|
+
|| output.parentUuid !== command.uuid
|
|
47
|
+
|| Date.parse(String(output.timestamp)) < Date.parse(String(command.timestamp)))
|
|
48
|
+
return null;
|
|
49
|
+
if (Date.parse(String(command.timestamp)) <= after) {
|
|
50
|
+
// PostCompact already supplies the completion edge before /compact's stdout is appended.
|
|
51
|
+
return /^\s*<command-name>\/compact<\/command-name>/i.test(commandText) ? undefined : null;
|
|
52
|
+
}
|
|
53
|
+
return `claude-local-command:${sessionId}:${output.uuid}`;
|
|
54
|
+
}
|