@aiwayds/dsh-tui-pi 2.11.4 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh-CN.md +1 -1
- package/lib/agents.d.ts +5 -1
- package/lib/agents.js +28 -3
- package/lib/agents.js.map +1 -1
- package/lib/history.js +2 -2
- package/lib/history.js.map +1 -1
- package/lib/index.js +28 -10
- package/lib/index.js.map +1 -1
- package/lib/live-widgets.d.ts +25 -9
- package/lib/live-widgets.js +81 -29
- package/lib/live-widgets.js.map +1 -1
- package/lib/log-repair.d.ts +10 -13
- package/lib/log-repair.js +11 -26
- package/lib/log-repair.js.map +1 -1
- package/lib/messages.d.ts +11 -1
- package/lib/messages.js +18 -4
- package/lib/messages.js.map +1 -1
- package/lib/retention.d.ts +10 -4
- package/lib/retention.js +15 -4
- package/lib/retention.js.map +1 -1
- package/lib/session-dir.d.ts +26 -0
- package/lib/session-dir.js +53 -0
- package/lib/session-dir.js.map +1 -0
- package/lib/session.d.ts +37 -44
- package/lib/session.js +167 -232
- package/lib/session.js.map +1 -1
- package/lib/sessions.d.ts +51 -4
- package/lib/sessions.js +45 -3
- package/lib/sessions.js.map +1 -1
- package/lib/subagent-policy.d.ts +41 -9
- package/lib/subagent-policy.js +133 -20
- package/lib/subagent-policy.js.map +1 -1
- package/lib/subagent-status-tool.d.ts +50 -0
- package/lib/subagent-status-tool.js +125 -0
- package/lib/subagent-status-tool.js.map +1 -0
- package/lib/theme-settings.d.ts +12 -1
- package/lib/theme-settings.js +12 -0
- package/lib/theme-settings.js.map +1 -1
- package/package.json +5 -5
- package/skills/dsh-tui-pi-config/SKILL.md +3 -2
- package/lib/writer-lock.d.ts +0 -70
- package/lib/writer-lock.js +0 -206
- package/lib/writer-lock.js.map +0 -1
package/lib/session.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
|
14
14
|
import { SettingsConflictError } from '@deepseek-ai/dsh-settings';
|
|
15
15
|
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
16
16
|
import { readAppendSystem } from "./append-system.js";
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
17
18
|
import { join } from 'node:path';
|
|
18
19
|
import { descriptorModelRoute, isAgentEnd, isAgentStart, isDcpCompactionNotice, isLlmRetry, isSubagentDescriptor, isTuiPluginInjection } from "./dsh-events.js";
|
|
19
20
|
import { loadModelProfiles, modelProfilesPath, resolvePinnedProfile } from "./model-profiles.js";
|
|
@@ -21,7 +22,7 @@ import { installSpawnToolFence, markTuiSurface } from "./subagent-policy.js";
|
|
|
21
22
|
import { RemoteSessionTail } from "./remote-tail.js";
|
|
22
23
|
import { sessionLogRoot } from "./sessions.js";
|
|
23
24
|
import { estimateContentTokens, estimateTextTokens } from "./tokens.js";
|
|
24
|
-
import {
|
|
25
|
+
import { projectKeyFor } from "./session-dir.js";
|
|
25
26
|
/**
|
|
26
27
|
* Register the APPEND_SYSTEM.md section on ONE agent's scoped context, so the
|
|
27
28
|
* user's orchestrator identity applies to the TUI's main agent ONLY. A section
|
|
@@ -170,12 +171,6 @@ export class DshSessionBridge {
|
|
|
170
171
|
resuming;
|
|
171
172
|
/** Target id of the in-flight resume — see `resume()`/`getResumingSessionId()`. */
|
|
172
173
|
resumeTargetId;
|
|
173
|
-
/**
|
|
174
|
-
* Session dirs whose cross-process writer lock THIS bridge established via
|
|
175
|
-
* a cold arm. Release is bound to the owning handle's disposal
|
|
176
|
-
* (`ownWithLock`), so the set itself only tracks double-release safety.
|
|
177
|
-
*/
|
|
178
|
-
heldLockDirs = new Set();
|
|
179
174
|
/** Active cross-process read-only view (see watchRemote). */
|
|
180
175
|
remoteTail;
|
|
181
176
|
/** Session watched read-only right now. */
|
|
@@ -184,12 +179,8 @@ export class DshSessionBridge {
|
|
|
184
179
|
remoteFollowups = [];
|
|
185
180
|
promotingRemote = false;
|
|
186
181
|
promoteTimer;
|
|
187
|
-
idleReleaseTimer;
|
|
188
|
-
/** Writer lock taken for the CURRENTLY bound local agent (see drive arms). */
|
|
189
|
-
driveLock;
|
|
190
182
|
/** Test seam + tuning for the watcher (interval/decoder injection). */
|
|
191
183
|
remoteTailOptions;
|
|
192
|
-
idleReleaseDelayMs;
|
|
193
184
|
promoteIntervalMs;
|
|
194
185
|
/** Serializes watch-lifecycle ops against the promotion poll. */
|
|
195
186
|
opChain = Promise.resolve();
|
|
@@ -301,11 +292,16 @@ export class DshSessionBridge {
|
|
|
301
292
|
trackedSessions = new Set();
|
|
302
293
|
/** `${runId}:${seq}` → childId, for `tool-workflow/agent-end` pairing. */
|
|
303
294
|
runSeqToChild = new Map();
|
|
295
|
+
/**
|
|
296
|
+
* Session id → the open attempt's turn/step, pinned by `agent/assistant-stream`
|
|
297
|
+
* start frames so chunk frames (which carry no turn/step) can drive the same
|
|
298
|
+
* fold paths the old firehose `assistant/chunk` events did. Cleared on end.
|
|
299
|
+
*/
|
|
300
|
+
openAttempts = new Map();
|
|
304
301
|
constructor(ctx, callbacks, options = {}) {
|
|
305
302
|
this.ctx = ctx;
|
|
306
303
|
this.callbacks = callbacks;
|
|
307
304
|
this.remoteTailOptions = options.remoteTailOptions ?? {};
|
|
308
|
-
this.idleReleaseDelayMs = options.idleReleaseDelayMs ?? 800;
|
|
309
305
|
this.promoteIntervalMs = options.promoteIntervalMs ?? 1000;
|
|
310
306
|
// Footer shows provider/model from the very first frame — read the cwd
|
|
311
307
|
// pin (if any) else the composed default selection eagerly; a session
|
|
@@ -369,13 +365,26 @@ export class DshSessionBridge {
|
|
|
369
365
|
this.callbacks.onEvent(event);
|
|
370
366
|
}
|
|
371
367
|
}));
|
|
368
|
+
// dsh 0.1.5-rc.1: the incremental streaming deltas left the firehose (it
|
|
369
|
+
// now delivers settlements only) and moved to per-agent
|
|
370
|
+
// `agent/assistant-stream` frames — start pins the attempt's turn/step,
|
|
371
|
+
// chunk frames carry the raw deltas, end closes the attempt. Root-scope
|
|
372
|
+
// listening sees every agent; frames route by session id: the bridge's
|
|
373
|
+
// own session drives the transcript/typewriter + occupancy estimate,
|
|
374
|
+
// tracked children fold into the live rows' content tail.
|
|
375
|
+
this.disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
376
|
+
try {
|
|
377
|
+
this.onStreamFrame(String(agent.id), frame);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// A malformed frame must never take the bridge down.
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
372
383
|
this.disposers.push(ctx.on('agent/status', ({ agent, status }) => {
|
|
373
384
|
if (this.handle === undefined || agent.id !== this.handle.agent.id)
|
|
374
385
|
return;
|
|
375
386
|
this.running = status === 'running';
|
|
376
387
|
this.callbacks.onStatus(status);
|
|
377
|
-
if (status === 'idle')
|
|
378
|
-
this.scheduleIdleRelease();
|
|
379
388
|
}));
|
|
380
389
|
// Round-count reconcile fallback: keep "rounds" and the maxRounds policy
|
|
381
390
|
// live even when a child's own events never bubble to this plugin (see
|
|
@@ -657,11 +666,34 @@ export class DshSessionBridge {
|
|
|
657
666
|
}
|
|
658
667
|
async watchRemoteInner(sessionId) {
|
|
659
668
|
await this.stopRemoteWatch();
|
|
660
|
-
//
|
|
669
|
+
// The jsonl backend's own dir derivation — no cwd, no view.
|
|
661
670
|
const cwd = await this.headerCwdOf(sessionId);
|
|
662
671
|
if (cwd === undefined)
|
|
663
672
|
throw new Error(`cannot locate the log of ${sessionId} for read-only viewing`);
|
|
664
|
-
const
|
|
673
|
+
const dir = join(sessionLogRoot(), projectKeyFor(cwd), sessionId);
|
|
674
|
+
// The artifact name carries the format generation since the V3 format
|
|
675
|
+
// (session.v3.jsonl[.zstd]); legacy sessions keep session.jsonl[.zstd].
|
|
676
|
+
// Prefer the current generation, compressed over raw; when nothing is
|
|
677
|
+
// on disk yet, fall back to the legacy canonical name — the tail
|
|
678
|
+
// tolerates a not-yet-existing file.
|
|
679
|
+
const candidates = [
|
|
680
|
+
'session.v3.jsonl.zstd',
|
|
681
|
+
'session.v3.jsonl',
|
|
682
|
+
'session.jsonl.zstd',
|
|
683
|
+
'session.jsonl',
|
|
684
|
+
];
|
|
685
|
+
let file = join(dir, 'session.jsonl.zstd');
|
|
686
|
+
for (const name of candidates) {
|
|
687
|
+
const candidate = join(dir, name);
|
|
688
|
+
try {
|
|
689
|
+
statSync(candidate);
|
|
690
|
+
file = candidate;
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// Not this name — try the next one.
|
|
695
|
+
}
|
|
696
|
+
}
|
|
665
697
|
// The firehose gate uses this id; harmless cross-process (nothing in
|
|
666
698
|
// THIS process will publish for it) and required so a later local agent
|
|
667
699
|
// for the SAME id (post-switch) re-binds cleanly.
|
|
@@ -726,40 +758,11 @@ export class DshSessionBridge {
|
|
|
726
758
|
this.remoteTail = undefined;
|
|
727
759
|
}
|
|
728
760
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*/
|
|
735
|
-
scheduleIdleRelease() {
|
|
736
|
-
if (this.idleReleaseTimer !== undefined)
|
|
737
|
-
clearTimeout(this.idleReleaseTimer);
|
|
738
|
-
this.idleReleaseTimer = setTimeout(() => {
|
|
739
|
-
this.idleReleaseTimer = undefined;
|
|
740
|
-
if (this.running)
|
|
741
|
-
return;
|
|
742
|
-
// Contained: foreign-shaped agents must not let a release timer blow
|
|
743
|
-
// up the process; when the inbox is unreadable we conservatively keep
|
|
744
|
-
// the lock.
|
|
745
|
-
try {
|
|
746
|
-
if (this.getPendingPrompts().length > 0)
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
const lock = this.driveLock;
|
|
753
|
-
if (lock === undefined)
|
|
754
|
-
return;
|
|
755
|
-
void this.releaseColdLock(lock);
|
|
756
|
-
}, this.idleReleaseDelayMs);
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* Promote poll tick: viewer + queued follow-ups + watched driver idle →
|
|
760
|
-
* race for the writer lock; winning hands to onRemotePromotable (UI
|
|
761
|
-
* rebuild via resume + queue drain), losing retries next tick. All async
|
|
762
|
-
* sequencing stays in THIS loop — never inside event callbacks.
|
|
761
|
+
* Promote poll tick: viewer + queued follow-ups + watched driver gone →
|
|
762
|
+
* attempt the takeover (onRemotePromotable rebuilds the UI via resume +
|
|
763
|
+
* queue drain); an owned session still refuses the resume and we retry
|
|
764
|
+
* next tick. All async sequencing stays in THIS loop — never inside
|
|
765
|
+
* event callbacks.
|
|
763
766
|
*/
|
|
764
767
|
maybePromoteRemote() {
|
|
765
768
|
return this.runExclusive(async () => {
|
|
@@ -776,26 +779,15 @@ export class DshSessionBridge {
|
|
|
776
779
|
if (promote === undefined)
|
|
777
780
|
return;
|
|
778
781
|
this.promotingRemote = true;
|
|
779
|
-
let locked = false;
|
|
780
|
-
try {
|
|
781
|
-
await this.acquireSessionLock(id);
|
|
782
|
-
locked = true;
|
|
783
|
-
}
|
|
784
|
-
catch {
|
|
785
|
-
/* still held / driver alive — retry next poll */
|
|
786
|
-
}
|
|
787
|
-
if (!locked) {
|
|
788
|
-
this.promotingRemote = false;
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
782
|
await this.stopRemoteWatch(); // ends the view BEFORE any UI rebuild
|
|
792
783
|
try {
|
|
793
784
|
await promote(id);
|
|
794
785
|
this.promotingRemote = false;
|
|
795
786
|
}
|
|
796
787
|
catch {
|
|
797
|
-
//
|
|
798
|
-
//
|
|
788
|
+
// The takeover failed — the other process still owns the session (the
|
|
789
|
+
// host lease refused the resume) or the rebuild broke: back to
|
|
790
|
+
// spectating; a later poll retries once the owner lets go.
|
|
799
791
|
this.promotingRemote = false;
|
|
800
792
|
await this.watchRemote(id).catch(() => { });
|
|
801
793
|
}
|
|
@@ -948,8 +940,6 @@ export class DshSessionBridge {
|
|
|
948
940
|
}
|
|
949
941
|
/** Dispose the live agent (if any) and stop event subscriptions. */
|
|
950
942
|
async dispose() {
|
|
951
|
-
if (this.idleReleaseTimer !== undefined)
|
|
952
|
-
clearTimeout(this.idleReleaseTimer);
|
|
953
943
|
await this.stopRemoteWatch();
|
|
954
944
|
for (const dispose of this.disposers.splice(0)) {
|
|
955
945
|
try {
|
|
@@ -1036,57 +1026,6 @@ export class DshSessionBridge {
|
|
|
1036
1026
|
return undefined;
|
|
1037
1027
|
}
|
|
1038
1028
|
}
|
|
1039
|
-
/**
|
|
1040
|
-
* Single-writer guard for COLD arms (create / cold resume) only — the
|
|
1041
|
-
* adopt arm above shares the live instance in-process and must stay
|
|
1042
|
-
* lock-free. Locks sit beside the jsonl log so every process that could
|
|
1043
|
-
* append to it competes on one file; a live foreign holder throws
|
|
1044
|
-
* WriterLockedError (surfaces as "Cannot resume <id>: session is locked
|
|
1045
|
-
* by a live process …"). `knownCwd` skips the header scan for creations,
|
|
1046
|
-
* which always target process.cwd().
|
|
1047
|
-
*/
|
|
1048
|
-
async acquireSessionLock(sessionId, knownCwd) {
|
|
1049
|
-
const cwd = knownCwd ?? (await this.headerCwdOf(sessionId));
|
|
1050
|
-
if (cwd === undefined || cwd === '') {
|
|
1051
|
-
// No trustworthy cwd → the derived path would be a decoy the real
|
|
1052
|
-
// writer never touches (fail-open, same policy as the feishu guard):
|
|
1053
|
-
// locking a decoy would silently protect nothing while pretending to.
|
|
1054
|
-
return undefined;
|
|
1055
|
-
}
|
|
1056
|
-
const dir = join(sessionLogRoot(), projectKeyFor(cwd), sessionId);
|
|
1057
|
-
const result = await acquireWriterLock(dir);
|
|
1058
|
-
if (!result.ok)
|
|
1059
|
-
throw new WriterLockedError(result.holder);
|
|
1060
|
-
this.heldLockDirs.add(dir);
|
|
1061
|
-
return { dir };
|
|
1062
|
-
}
|
|
1063
|
-
/** Undo a guard whose arm then failed — transient errors must not pin the session. */
|
|
1064
|
-
async releaseColdLock(lock) {
|
|
1065
|
-
if (lock === undefined)
|
|
1066
|
-
return;
|
|
1067
|
-
this.heldLockDirs.delete(lock.dir);
|
|
1068
|
-
await releaseOwnedWriterLock(lock.dir);
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* Bind lock release to an owned handle's disposal — every teardown path
|
|
1072
|
-
* already goes through handle.dispose(), so this is the single choke
|
|
1073
|
-
* point; switching away or exiting releases what we established.
|
|
1074
|
-
*/
|
|
1075
|
-
ownWithLock(handle, lock) {
|
|
1076
|
-
if (lock === undefined)
|
|
1077
|
-
return handle;
|
|
1078
|
-
const base = handle.dispose.bind(handle);
|
|
1079
|
-
const bridge = this;
|
|
1080
|
-
handle.dispose = async () => {
|
|
1081
|
-
try {
|
|
1082
|
-
await base();
|
|
1083
|
-
}
|
|
1084
|
-
finally {
|
|
1085
|
-
await bridge.releaseColdLock(lock);
|
|
1086
|
-
}
|
|
1087
|
-
};
|
|
1088
|
-
return handle;
|
|
1089
|
-
}
|
|
1090
1029
|
/**
|
|
1091
1030
|
* Tear down the current live binding — the shared prelude of every session
|
|
1092
1031
|
* switch (resume, fork): stop the remote watch, dispose the handle, clear
|
|
@@ -1189,36 +1128,27 @@ export class DshSessionBridge {
|
|
|
1189
1128
|
}
|
|
1190
1129
|
return adopted;
|
|
1191
1130
|
}
|
|
1192
|
-
// Cold arm —
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
},
|
|
1214
|
-
});
|
|
1215
|
-
}
|
|
1216
|
-
catch (error) {
|
|
1217
|
-
await this.releaseColdLock(lock);
|
|
1218
|
-
throw error;
|
|
1219
|
-
}
|
|
1220
|
-
this.driveLock = lock;
|
|
1221
|
-
this.handle = this.ownWithLock(resumed, lock);
|
|
1131
|
+
// Cold arm — cross-process single-writer arbitration is the HOST's
|
|
1132
|
+
// job since 0.1.5: `agents.resume` opens the write handle under the
|
|
1133
|
+
// host's kernel lease, and a session another process drives refuses
|
|
1134
|
+
// here with SessionAlreadyOwnedError (surfaces as the /resume
|
|
1135
|
+
// read-only fallback).
|
|
1136
|
+
this.handle = await this.ctx.agents.resume({
|
|
1137
|
+
resumeSessionId: sessionId,
|
|
1138
|
+
agentOptions: this.selection ?? {},
|
|
1139
|
+
// Install the mutable selection so `/model` can live-switch the route,
|
|
1140
|
+
// the APPEND_SYSTEM.md section on this agent ONLY (never its
|
|
1141
|
+
// subagents), and the spawn-tool hide so the agent sees a single
|
|
1142
|
+
// `use_agent` delegation entry. The surface marker scopes the live
|
|
1143
|
+
// disableSubagent guard to THIS agent (resume runs setup too, so a
|
|
1144
|
+
// resumed session is re-marked without relying on persisted meta).
|
|
1145
|
+
setup: async (agentCtx) => {
|
|
1146
|
+
markTuiSurface(agentCtx);
|
|
1147
|
+
installModelSelection(agentCtx, this.selectionRef);
|
|
1148
|
+
installAppendSystem(agentCtx);
|
|
1149
|
+
installSpawnToolFence(agentCtx);
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1222
1152
|
this.sessionId = sessionId;
|
|
1223
1153
|
this.trackedSessions.add(String(sessionId));
|
|
1224
1154
|
return this.handle;
|
|
@@ -1236,9 +1166,9 @@ export class DshSessionBridge {
|
|
|
1236
1166
|
* Replay a persisted session log into the transcript and stats — the second
|
|
1237
1167
|
* half of a resume (call after `resume()`). Stats are rebuilt from zero so
|
|
1238
1168
|
* the footer reflects the resumed session only; replaying the same log
|
|
1239
|
-
* twice is idempotent.
|
|
1240
|
-
* `assistant/message`
|
|
1241
|
-
*
|
|
1169
|
+
* twice is idempotent. V3 logs carry no streaming chunks at all — the
|
|
1170
|
+
* finalized `assistant/message` holds the full text (and its embedded
|
|
1171
|
+
* stream, which replay never needs).
|
|
1242
1172
|
*/
|
|
1243
1173
|
replay(events) {
|
|
1244
1174
|
this.stats.inputTokens = 0;
|
|
@@ -1254,8 +1184,6 @@ export class DshSessionBridge {
|
|
|
1254
1184
|
this.pendingTokens = 0;
|
|
1255
1185
|
const sessionId = this.sessionId;
|
|
1256
1186
|
for (const event of events) {
|
|
1257
|
-
if (event.type === 'assistant/chunk')
|
|
1258
|
-
continue;
|
|
1259
1187
|
// Replay feeds the parent log only — fold its persisted workflow events
|
|
1260
1188
|
// through the same tracking logic so the agents view is rebuilt; child
|
|
1261
1189
|
// live events arrive via the subscription after resume.
|
|
@@ -1316,20 +1244,11 @@ export class DshSessionBridge {
|
|
|
1316
1244
|
this.stats.contextTokens = this.contextTokens();
|
|
1317
1245
|
break;
|
|
1318
1246
|
}
|
|
1319
|
-
case 'assistant/
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
// includes reasoning tokens at snapshot time, so the live estimate
|
|
1324
|
-
// must count reasoning too to match that accounting (the next billed
|
|
1325
|
-
// assistant/message corrects the final value anyway).
|
|
1326
|
-
const chunk = event.data.chunk;
|
|
1327
|
-
if ((chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') && chunk.text !== '') {
|
|
1328
|
-
this.pendingTokens += estimateTextTokens(chunk.text ?? '');
|
|
1329
|
-
this.stats.contextTokens = this.contextTokens();
|
|
1330
|
-
}
|
|
1247
|
+
case 'assistant/attempt':
|
|
1248
|
+
// A settled attempt that produced no surface message (failed, retried,
|
|
1249
|
+
// cancelled). No transcript, stats, or occupancy effect — the next
|
|
1250
|
+
// billed assistant/message carries the step's real usage.
|
|
1331
1251
|
break;
|
|
1332
|
-
}
|
|
1333
1252
|
case 'tool/result':
|
|
1334
1253
|
// Tool results enter the next request — priced into the occupancy.
|
|
1335
1254
|
this.pendingTokens += estimateToolResultTokens(event.data);
|
|
@@ -1344,6 +1263,69 @@ export class DshSessionBridge {
|
|
|
1344
1263
|
break;
|
|
1345
1264
|
}
|
|
1346
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* One `agent/assistant-stream` frame of any session in the process (dsh
|
|
1268
|
+
* 0.1.5-rc.1 moved the live deltas off the firehose). Chunk frames carry no
|
|
1269
|
+
* turn/step — the attempt's start frame pinned them in {@link openAttempts}.
|
|
1270
|
+
*/
|
|
1271
|
+
onStreamFrame(sessionKey, frame) {
|
|
1272
|
+
if (frame.type === 'start') {
|
|
1273
|
+
this.openAttempts.set(sessionKey, { turn: frame.turn, step: frame.step });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (frame.type === 'end') {
|
|
1277
|
+
this.openAttempts.delete(sessionKey);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const chunk = frame.chunk;
|
|
1281
|
+
if ((chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta') || (chunk.text ?? '') === '') {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const delta = chunk.text ?? '';
|
|
1285
|
+
if (this.sessionId !== undefined && sessionKey === String(this.sessionId)) {
|
|
1286
|
+
// The bridge's own session: the occupancy estimate follows the stream
|
|
1287
|
+
// (both text and reasoning are priced — usage.outputTokens includes
|
|
1288
|
+
// reasoning tokens at snapshot time), and the transcript's typewriter
|
|
1289
|
+
// plus the think/tool phase machine stay live between settlements.
|
|
1290
|
+
const attempt = this.openAttempts.get(sessionKey);
|
|
1291
|
+
const turn = attempt?.turn ?? 0;
|
|
1292
|
+
const step = attempt?.step ?? 0;
|
|
1293
|
+
this.pendingTokens += estimateTextTokens(delta);
|
|
1294
|
+
this.stats.contextTokens = this.contextTokens();
|
|
1295
|
+
this.callbacks.onStreamDelta?.(turn, step, chunk);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (this.trackedSessions.has(sessionKey) && this.childSessions.has(sessionKey)) {
|
|
1299
|
+
this.foldChildStreamDelta(sessionKey, delta);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Fold one streaming delta of a tracked CHILD into the live row: the
|
|
1304
|
+
* content tail (text AND reasoning — never a tool name, always the child's
|
|
1305
|
+
* own output) and the pending-occupancy estimate. The assembled
|
|
1306
|
+
* `assistant/message` later replaces the tail with the authoritative last
|
|
1307
|
+
* line and resets the estimate against real usage.
|
|
1308
|
+
*/
|
|
1309
|
+
foldChildStreamDelta(sessionId, delta) {
|
|
1310
|
+
const view = this.agentViews.get(sessionId);
|
|
1311
|
+
if (view === undefined)
|
|
1312
|
+
return;
|
|
1313
|
+
const buffer = bumpChildStream(this.childStreams.get(sessionId) ?? '', delta);
|
|
1314
|
+
this.childStreams.set(sessionId, buffer);
|
|
1315
|
+
const lastLine = lastBufferLine(buffer);
|
|
1316
|
+
this.childPending.set(sessionId, (this.childPending.get(sessionId) ?? 0) + estimateTextTokens(delta));
|
|
1317
|
+
const contextTokens = this.childContextTokens(sessionId);
|
|
1318
|
+
const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
|
|
1319
|
+
const contextChanged = contextTokens !== view.contextTokens;
|
|
1320
|
+
if (lineChanged || contextChanged) {
|
|
1321
|
+
this.agentViews.set(sessionId, {
|
|
1322
|
+
...view,
|
|
1323
|
+
...(lineChanged ? { lastLine } : {}),
|
|
1324
|
+
...(contextChanged ? { contextTokens } : {}),
|
|
1325
|
+
});
|
|
1326
|
+
this.emitLive();
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1347
1329
|
/**
|
|
1348
1330
|
* The current context-occupancy estimate: the latest assistant/message's
|
|
1349
1331
|
* billed input (uncached + cache read + cache write) and output, plus the
|
|
@@ -1534,41 +1516,6 @@ export class DshSessionBridge {
|
|
|
1534
1516
|
}
|
|
1535
1517
|
this.childStreams.delete(sessionId);
|
|
1536
1518
|
}
|
|
1537
|
-
else if (event.type === 'assistant/chunk') {
|
|
1538
|
-
// Live content tail: fold the child's streaming deltas (text AND
|
|
1539
|
-
// reasoning) so the compact row's last line refreshes while the
|
|
1540
|
-
// child works — never a tool name, always its own output. The
|
|
1541
|
-
// assembled assistant/message later replaces it with the
|
|
1542
|
-
// authoritative last line.
|
|
1543
|
-
const chunk = event.data.chunk;
|
|
1544
|
-
if (chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') {
|
|
1545
|
-
const delta = chunk.text ?? '';
|
|
1546
|
-
if (delta !== '') {
|
|
1547
|
-
const buffer = bumpChildStream(this.childStreams.get(sessionId) ?? '', delta);
|
|
1548
|
-
this.childStreams.set(sessionId, buffer);
|
|
1549
|
-
const lastLine = lastBufferLine(buffer);
|
|
1550
|
-
// Streamed output (text AND reasoning) grows the child's current
|
|
1551
|
-
// context too — usage.outputTokens includes reasoning tokens at
|
|
1552
|
-
// snapshot, so the live estimate prices both; the compact X/Y
|
|
1553
|
-
// follows live.
|
|
1554
|
-
const wasPending = this.childPending.get(sessionId) ?? 0;
|
|
1555
|
-
const lineChanged = lastLine !== undefined && lastLine !== view.lastLine;
|
|
1556
|
-
// The outer guard already narrowed to text/reasoning deltas, so
|
|
1557
|
-
// this branch is unconditional.
|
|
1558
|
-
this.childPending.set(sessionId, wasPending + estimateTextTokens(delta));
|
|
1559
|
-
const contextTokens = this.childContextTokens(sessionId);
|
|
1560
|
-
const contextChanged = contextTokens !== view.contextTokens;
|
|
1561
|
-
if (lineChanged || contextChanged) {
|
|
1562
|
-
this.agentViews.set(sessionId, {
|
|
1563
|
-
...view,
|
|
1564
|
-
...(lineChanged ? { lastLine } : {}),
|
|
1565
|
-
...(contextChanged ? { contextTokens } : {}),
|
|
1566
|
-
});
|
|
1567
|
-
changed = true;
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
1519
|
else if (event.type === 'user/message') {
|
|
1573
1520
|
// The child's own prompts/injects enter its next request — the
|
|
1574
1521
|
// occupancy follows (the content tail is never touched here). A
|
|
@@ -1777,11 +1724,8 @@ export class DshSessionBridge {
|
|
|
1777
1724
|
*/
|
|
1778
1725
|
async createSession(fork) {
|
|
1779
1726
|
this.seedSelectionFromDefault();
|
|
1780
|
-
// Mint the id HERE so the pre-create lock covers exactly the directory
|
|
1781
|
-
// agents.create is about to persist into (same cold-arm guard as resume).
|
|
1782
1727
|
const sessionId = crypto.randomUUID();
|
|
1783
1728
|
const cwd = process.cwd();
|
|
1784
|
-
const lock = await this.acquireSessionLock(sessionId, cwd);
|
|
1785
1729
|
// A fork records ITS OWN preset (what the new session composes under —
|
|
1786
1730
|
// possibly none, keeping meta.agentPreset absent); a plain create
|
|
1787
1731
|
// records the current selection.
|
|
@@ -1793,35 +1737,26 @@ export class DshSessionBridge {
|
|
|
1793
1737
|
...presetMeta,
|
|
1794
1738
|
...(fork !== undefined ? { parentSession: fork.parentSessionId, isSeeded: true } : {}),
|
|
1795
1739
|
};
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
},
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
catch (error) {
|
|
1820
|
-
await this.releaseColdLock(lock);
|
|
1821
|
-
throw error;
|
|
1822
|
-
}
|
|
1823
|
-
this.driveLock = lock;
|
|
1824
|
-
return this.ownWithLock(handle, lock);
|
|
1740
|
+
return await this.ctx.agents.create({
|
|
1741
|
+
sessionId: SessionId(sessionId),
|
|
1742
|
+
meta,
|
|
1743
|
+
...(fork === undefined ? {} : {
|
|
1744
|
+
seed: fork.seed,
|
|
1745
|
+
inheritedEventCount: SessionLogOffset(fork.seed.length),
|
|
1746
|
+
}),
|
|
1747
|
+
agentOptions: this.selection ?? {},
|
|
1748
|
+
// Install the mutable selection so `/model` can live-switch the route,
|
|
1749
|
+
// the APPEND_SYSTEM.md section on this agent ONLY (never its
|
|
1750
|
+
// subagents), and the spawn-tool hide so the agent sees a single
|
|
1751
|
+
// `use_agent` delegation entry. The surface marker scopes the live
|
|
1752
|
+
// disableSubagent guard to THIS agent.
|
|
1753
|
+
setup: async (agentCtx) => {
|
|
1754
|
+
markTuiSurface(agentCtx);
|
|
1755
|
+
installModelSelection(agentCtx, this.selectionRef);
|
|
1756
|
+
installAppendSystem(agentCtx);
|
|
1757
|
+
installSpawnToolFence(agentCtx);
|
|
1758
|
+
},
|
|
1759
|
+
});
|
|
1825
1760
|
}
|
|
1826
1761
|
/** The model selection shown in the footer (live value after `/model`). */
|
|
1827
1762
|
getSelection() {
|