@aiwayds/dsh-tui-pi 2.12.0 → 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/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 { acquireWriterLock, projectKeyFor, releaseOwnedWriterLock, WriterLockedError } from "./writer-lock.js";
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
- // Same derivation as acquireSessionLock — no cwd, no view.
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 file = join(sessionLogRoot(), projectKeyFor(cwd), sessionId, 'session.jsonl.zstd');
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
- * Contract "idle releases the write; next input takes it": when OUR agent
730
- * settles with nothing pending locally (own re-wake included), drop the
731
- * writer lock so another surface can take over. Delayed once and
732
- * re-checked because upstream may latch idle only to immediately replay
733
- * a queued wake as a new turn.
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
- // Took the lock but the rebuild failed: back to spectating; ownership
798
- // persists cooperatively so the retry below costs nothing extra.
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 — guarded: without the registry hit above, nothing here can
1193
- // see whether ANOTHER process owns this id right now. Take the writer
1194
- // lock beside its jsonl first so a concurrent owner refuses us loudly
1195
- // instead of two appends interleaving into "corrupt session log".
1196
- const lock = await this.acquireSessionLock(String(sessionId));
1197
- let resumed;
1198
- try {
1199
- resumed = await this.ctx.agents.resume({
1200
- resumeSessionId: sessionId,
1201
- agentOptions: this.selection ?? {},
1202
- // Install the mutable selection so `/model` can live-switch the route,
1203
- // the APPEND_SYSTEM.md section on this agent ONLY (never its
1204
- // subagents), and the spawn-tool hide so the agent sees a single
1205
- // `use_agent` delegation entry. The surface marker scopes the live
1206
- // disableSubagent guard to THIS agent (resume runs setup too, so a
1207
- // resumed session is re-marked without relying on persisted meta).
1208
- setup: async (agentCtx) => {
1209
- markTuiSurface(agentCtx);
1210
- installModelSelection(agentCtx, this.selectionRef);
1211
- installAppendSystem(agentCtx);
1212
- installSpawnToolFence(agentCtx);
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. `assistant/chunk` events are skipped: the finalized
1240
- * `assistant/message` carries the full text, and replaying chunks would
1241
- * re-run the quadratic streaming assembly for every historical step.
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/chunk': {
1320
- // Streamed output of the in-flight request — the next request's
1321
- // context grows with it, so the occupancy follows the stream live.
1322
- // Both text and reasoning deltas are priced: usage.outputTokens
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
- let handle;
1797
- try {
1798
- handle = await this.ctx.agents.create({
1799
- sessionId: SessionId(sessionId),
1800
- meta,
1801
- ...(fork === undefined ? {} : {
1802
- seed: fork.seed,
1803
- inheritedEventCount: SessionLogOffset(fork.seed.length),
1804
- }),
1805
- agentOptions: this.selection ?? {},
1806
- // Install the mutable selection so `/model` can live-switch the route,
1807
- // the APPEND_SYSTEM.md section on this agent ONLY (never its
1808
- // subagents), and the spawn-tool hide so the agent sees a single
1809
- // `use_agent` delegation entry. The surface marker scopes the live
1810
- // disableSubagent guard to THIS agent.
1811
- setup: async (agentCtx) => {
1812
- markTuiSurface(agentCtx);
1813
- installModelSelection(agentCtx, this.selectionRef);
1814
- installAppendSystem(agentCtx);
1815
- installSpawnToolFence(agentCtx);
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() {