@aiwayds/dsh-tui-pi 0.28.0 → 0.29.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/index.js +49 -2
- package/lib/index.js.map +1 -1
- package/lib/remote-tail.d.ts +49 -0
- package/lib/remote-tail.js +114 -0
- package/lib/remote-tail.js.map +1 -0
- package/lib/session.d.ts +96 -1
- package/lib/session.js +346 -39
- package/lib/session.js.map +1 -1
- package/lib/sessions.d.ts +12 -0
- package/lib/sessions.js +1 -1
- package/lib/sessions.js.map +1 -1
- package/lib/writer-lock.d.ts +70 -0
- package/lib/writer-lock.js +206 -0
- package/lib/writer-lock.js.map +1 -0
- package/package.json +2 -2
- package/scripts/repair-session-log.mjs +232 -0
package/lib/session.js
CHANGED
|
@@ -14,10 +14,14 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
|
14
14
|
import { settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings';
|
|
15
15
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
16
16
|
import { readAppendSystem } from "./append-system.js";
|
|
17
|
+
import { join } from 'node:path';
|
|
17
18
|
import { isAgentEnd, isAgentStart, isDcpCompactionNotice, isLlmRetry, isSubagentDescriptor, isTuiPluginInjection } from "./dsh-events.js";
|
|
18
19
|
import { loadModelProfiles, modelProfilesPath, resolvePinnedProfile } from "./model-profiles.js";
|
|
19
20
|
import { installSpawnToolFence, markTuiSurface } from "./subagent-policy.js";
|
|
21
|
+
import { RemoteSessionTail } from "./remote-tail.js";
|
|
22
|
+
import { sessionLogRoot } from "./sessions.js";
|
|
20
23
|
import { estimateContentTokens, estimateTextTokens } from "./tokens.js";
|
|
24
|
+
import { acquireWriterLock, projectKeyFor, releaseOwnedWriterLock, WriterLockedError } from "./writer-lock.js";
|
|
21
25
|
/**
|
|
22
26
|
* Register the APPEND_SYSTEM.md section on ONE agent's scoped context, so the
|
|
23
27
|
* user's orchestrator identity applies to the TUI's main agent ONLY. A section
|
|
@@ -166,6 +170,34 @@ export class DshSessionBridge {
|
|
|
166
170
|
resuming;
|
|
167
171
|
/** Target id of the in-flight resume — see `resume()`/`getResumingSessionId()`. */
|
|
168
172
|
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
|
+
/** Active cross-process read-only view (see watchRemote). */
|
|
180
|
+
remoteTail;
|
|
181
|
+
/** Session watched read-only right now. */
|
|
182
|
+
remoteWatchId;
|
|
183
|
+
/** Follow-ups typed while watching — sent once promotion wins the lock. */
|
|
184
|
+
remoteFollowups = [];
|
|
185
|
+
promotingRemote = false;
|
|
186
|
+
promoteTimer;
|
|
187
|
+
idleReleaseTimer;
|
|
188
|
+
/** Writer lock taken for the CURRENTLY bound local agent (see drive arms). */
|
|
189
|
+
driveLock;
|
|
190
|
+
/** Test seam + tuning for the watcher (interval/decoder injection). */
|
|
191
|
+
remoteTailOptions;
|
|
192
|
+
idleReleaseDelayMs;
|
|
193
|
+
promoteIntervalMs;
|
|
194
|
+
/** Serializes watch-lifecycle ops against the promotion poll. */
|
|
195
|
+
opChain = Promise.resolve();
|
|
196
|
+
runExclusive(op) {
|
|
197
|
+
const next = this.opChain.then(op, op);
|
|
198
|
+
this.opChain = next.then(() => undefined, () => undefined);
|
|
199
|
+
return next;
|
|
200
|
+
}
|
|
169
201
|
disposers = [];
|
|
170
202
|
sessionId;
|
|
171
203
|
/** Agent preset id to pass to `meta.agentPreset` on the next `createSession`. */
|
|
@@ -269,9 +301,12 @@ export class DshSessionBridge {
|
|
|
269
301
|
trackedSessions = new Set();
|
|
270
302
|
/** `${runId}:${seq}` → childId, for `tool-workflow/agent-end` pairing. */
|
|
271
303
|
runSeqToChild = new Map();
|
|
272
|
-
constructor(ctx, callbacks) {
|
|
304
|
+
constructor(ctx, callbacks, options = {}) {
|
|
273
305
|
this.ctx = ctx;
|
|
274
306
|
this.callbacks = callbacks;
|
|
307
|
+
this.remoteTailOptions = options.remoteTailOptions ?? {};
|
|
308
|
+
this.idleReleaseDelayMs = options.idleReleaseDelayMs ?? 800;
|
|
309
|
+
this.promoteIntervalMs = options.promoteIntervalMs ?? 1000;
|
|
275
310
|
// Footer shows provider/model from the very first frame — read the cwd
|
|
276
311
|
// pin (if any) else the composed default selection eagerly; a session
|
|
277
312
|
// created later refreshes it the same way.
|
|
@@ -339,6 +374,8 @@ export class DshSessionBridge {
|
|
|
339
374
|
return;
|
|
340
375
|
this.running = status === 'running';
|
|
341
376
|
this.callbacks.onStatus(status);
|
|
377
|
+
if (status === 'idle')
|
|
378
|
+
this.scheduleIdleRelease();
|
|
342
379
|
}));
|
|
343
380
|
// Round-count reconcile fallback: keep "rounds" and the maxRounds policy
|
|
344
381
|
// live even when a child's own events never bubble to this plugin (see
|
|
@@ -456,6 +493,12 @@ export class DshSessionBridge {
|
|
|
456
493
|
}
|
|
457
494
|
/** Queue one user prompt, creating the session lazily on first use. */
|
|
458
495
|
async prompt(text) {
|
|
496
|
+
if (this.remoteTail !== undefined) {
|
|
497
|
+
// Lockless viewer: never refuse, QUEUE. The promote poll sends it the
|
|
498
|
+
// moment the driver goes idle and we win the writer lock.
|
|
499
|
+
this.remoteFollowups.push(text);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
459
502
|
const handle = await this.ensureSession();
|
|
460
503
|
const message = createUserMessage({
|
|
461
504
|
content: [{ type: 'text', text }],
|
|
@@ -463,6 +506,171 @@ export class DshSessionBridge {
|
|
|
463
506
|
});
|
|
464
507
|
handle.agent.followup(message);
|
|
465
508
|
}
|
|
509
|
+
/** While a remote watch is active input QUEUES instead of driving. */
|
|
510
|
+
isReadOnlyView() {
|
|
511
|
+
return this.remoteTail !== undefined;
|
|
512
|
+
}
|
|
513
|
+
/** Follow-ups typed while watching, oldest first; takes them all. */
|
|
514
|
+
takePendingRemoteFollowups() {
|
|
515
|
+
return this.remoteFollowups.splice(0);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* READ-ONLY cross-process view: another dsh process drives this session
|
|
519
|
+
* (the single-writer guard refused our cold resume), so sync DISPLAY from
|
|
520
|
+
* its persisted log instead — poll-decode, emit durable events through
|
|
521
|
+
* the same render pipeline a live session uses. Contract: latency ≈ poll
|
|
522
|
+
* interval, streaming detail omitted, every turn's final assistant
|
|
523
|
+
* message arrives. Replaces any prior binding; ends via stopRemoteWatch
|
|
524
|
+
* on dispose or the next resume.
|
|
525
|
+
*/
|
|
526
|
+
watchRemote(sessionId) {
|
|
527
|
+
return this.runExclusive(() => this.watchRemoteInner(sessionId));
|
|
528
|
+
}
|
|
529
|
+
async watchRemoteInner(sessionId) {
|
|
530
|
+
await this.stopRemoteWatch();
|
|
531
|
+
// Same derivation as acquireSessionLock — no cwd, no view.
|
|
532
|
+
const cwd = await this.headerCwdOf(sessionId);
|
|
533
|
+
if (cwd === undefined)
|
|
534
|
+
throw new Error(`cannot locate the log of ${sessionId} for read-only viewing`);
|
|
535
|
+
const file = join(sessionLogRoot(), projectKeyFor(cwd), sessionId, 'session.jsonl.zstd');
|
|
536
|
+
// The firehose gate uses this id; harmless cross-process (nothing in
|
|
537
|
+
// THIS process will publish for it) and required so a later local agent
|
|
538
|
+
// for the SAME id (post-switch) re-binds cleanly.
|
|
539
|
+
this.sessionId = SessionId(sessionId);
|
|
540
|
+
this.handle = undefined;
|
|
541
|
+
this.remoteWatchId = sessionId;
|
|
542
|
+
const tail = new RemoteSessionTail(file, {
|
|
543
|
+
onEvents: events => {
|
|
544
|
+
// A stopped tail's in-flight final tick must not paint after detach.
|
|
545
|
+
if (this.remoteTail !== tail)
|
|
546
|
+
return;
|
|
547
|
+
for (const raw of events) {
|
|
548
|
+
const event = raw;
|
|
549
|
+
// Per-event containment mirrors the firehose listener contract:
|
|
550
|
+
// one unsupported/malformed row must not kill the rest of the
|
|
551
|
+
// view batch. Projection and render dispatch are contained
|
|
552
|
+
// SEPARATELY so a projection gap cannot suppress rendering.
|
|
553
|
+
try {
|
|
554
|
+
this.applyEvent(event);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
/* projection drift — the view row still renders below */
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
this.callbacks.onEvent(event);
|
|
561
|
+
if (event.type === 'turn/start') {
|
|
562
|
+
this.running = true;
|
|
563
|
+
this.callbacks.onStatus('running');
|
|
564
|
+
}
|
|
565
|
+
else if (event.type === 'turn/end') {
|
|
566
|
+
this.running = false;
|
|
567
|
+
this.callbacks.onStatus('idle');
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
/* contained — keep rendering the remaining events */
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
}, this.remoteTailOptions);
|
|
576
|
+
this.remoteTail = tail;
|
|
577
|
+
// Deterministic backfill before returning: callers may render/report
|
|
578
|
+
// immediately; live ticks continue on the poll cadence afterwards.
|
|
579
|
+
await tail.tickOnce();
|
|
580
|
+
tail.start();
|
|
581
|
+
// Promotion lives OUTSIDE the event stream: one independent poll checks
|
|
582
|
+
// (queue non-empty ∧ driver idle ∵ running flag ∧ lock winnable). This
|
|
583
|
+
// keeps takeover state transitions off the render path entirely.
|
|
584
|
+
if (this.promoteTimer === undefined) {
|
|
585
|
+
this.promoteTimer = setInterval(() => void this.maybePromoteRemote(), this.promoteIntervalMs);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async stopRemoteWatch() {
|
|
589
|
+
if (this.promoteTimer !== undefined) {
|
|
590
|
+
clearInterval(this.promoteTimer);
|
|
591
|
+
this.promoteTimer = undefined;
|
|
592
|
+
}
|
|
593
|
+
this.remoteWatchId = undefined;
|
|
594
|
+
if (this.remoteTail === undefined)
|
|
595
|
+
return;
|
|
596
|
+
this.remoteTail.stop();
|
|
597
|
+
this.remoteTail = undefined;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Contract "idle releases the write; next input takes it": when OUR agent
|
|
601
|
+
* settles with nothing pending locally (own re-wake included), drop the
|
|
602
|
+
* writer lock so another surface can take over. Delayed once and
|
|
603
|
+
* re-checked because upstream may latch idle only to immediately replay
|
|
604
|
+
* a queued wake as a new turn.
|
|
605
|
+
*/
|
|
606
|
+
scheduleIdleRelease() {
|
|
607
|
+
if (this.idleReleaseTimer !== undefined)
|
|
608
|
+
clearTimeout(this.idleReleaseTimer);
|
|
609
|
+
this.idleReleaseTimer = setTimeout(() => {
|
|
610
|
+
this.idleReleaseTimer = undefined;
|
|
611
|
+
if (this.running)
|
|
612
|
+
return;
|
|
613
|
+
// Contained: foreign-shaped agents must not let a release timer blow
|
|
614
|
+
// up the process; when the inbox is unreadable we conservatively keep
|
|
615
|
+
// the lock.
|
|
616
|
+
try {
|
|
617
|
+
if (this.getPendingPrompts().length > 0)
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const lock = this.driveLock;
|
|
624
|
+
if (lock === undefined)
|
|
625
|
+
return;
|
|
626
|
+
void this.releaseColdLock(lock);
|
|
627
|
+
}, this.idleReleaseDelayMs);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Promote poll tick: viewer + queued follow-ups + watched driver idle →
|
|
631
|
+
* race for the writer lock; winning hands to onRemotePromotable (UI
|
|
632
|
+
* rebuild via resume + queue drain), losing retries next tick. All async
|
|
633
|
+
* sequencing stays in THIS loop — never inside event callbacks.
|
|
634
|
+
*/
|
|
635
|
+
maybePromoteRemote() {
|
|
636
|
+
return this.runExclusive(async () => {
|
|
637
|
+
await this.maybePromoteRemoteInner();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async maybePromoteRemoteInner() {
|
|
641
|
+
if (this.promotingRemote || this.running)
|
|
642
|
+
return;
|
|
643
|
+
const id = this.remoteWatchId;
|
|
644
|
+
if (id === undefined || this.remoteFollowups.length === 0)
|
|
645
|
+
return;
|
|
646
|
+
const promote = this.callbacks.onRemotePromotable;
|
|
647
|
+
if (promote === undefined)
|
|
648
|
+
return;
|
|
649
|
+
this.promotingRemote = true;
|
|
650
|
+
let locked = false;
|
|
651
|
+
try {
|
|
652
|
+
await this.acquireSessionLock(id);
|
|
653
|
+
locked = true;
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
/* still held / driver alive — retry next poll */
|
|
657
|
+
}
|
|
658
|
+
if (!locked) {
|
|
659
|
+
this.promotingRemote = false;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
await this.stopRemoteWatch(); // ends the view BEFORE any UI rebuild
|
|
663
|
+
try {
|
|
664
|
+
await promote(id);
|
|
665
|
+
this.promotingRemote = false;
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// Took the lock but the rebuild failed: back to spectating; ownership
|
|
669
|
+
// persists cooperatively so the retry below costs nothing extra.
|
|
670
|
+
this.promotingRemote = false;
|
|
671
|
+
await this.watchRemote(id).catch(() => { });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
466
674
|
/** The live agent, creating the session lazily when needed. */
|
|
467
675
|
async ensureAgent() {
|
|
468
676
|
const handle = await this.ensureSession();
|
|
@@ -534,6 +742,9 @@ export class DshSessionBridge {
|
|
|
534
742
|
}
|
|
535
743
|
/** Dispose the live agent (if any) and stop event subscriptions. */
|
|
536
744
|
async dispose() {
|
|
745
|
+
if (this.idleReleaseTimer !== undefined)
|
|
746
|
+
clearTimeout(this.idleReleaseTimer);
|
|
747
|
+
await this.stopRemoteWatch();
|
|
537
748
|
for (const dispose of this.disposers.splice(0)) {
|
|
538
749
|
try {
|
|
539
750
|
dispose();
|
|
@@ -601,6 +812,74 @@ export class DshSessionBridge {
|
|
|
601
812
|
if (handle !== undefined)
|
|
602
813
|
await handle.dispose();
|
|
603
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* Historical cwd for a session, from the persistence header list — the
|
|
817
|
+
* log lives under THAT project key. All access best-effort: an absent
|
|
818
|
+
* service or odd host yields undefined and the arm stays UNGUARDED
|
|
819
|
+
* (fail-open, never locks a decoy), never breaks the resume.
|
|
820
|
+
*/
|
|
821
|
+
async headerCwdOf(sessionId) {
|
|
822
|
+
try {
|
|
823
|
+
const persistence = this.ctx.get?.('sessionPersistence');
|
|
824
|
+
const stored = (await persistence?.list?.().catch(() => [])) ?? [];
|
|
825
|
+
const header = stored.find(candidate => String(candidate.id) === sessionId);
|
|
826
|
+
return typeof header?.cwd === 'string' && header.cwd !== '' ? header.cwd : undefined;
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Single-writer guard for COLD arms (create / cold resume) only — the
|
|
834
|
+
* adopt arm above shares the live instance in-process and must stay
|
|
835
|
+
* lock-free. Locks sit beside the jsonl log so every process that could
|
|
836
|
+
* append to it competes on one file; a live foreign holder throws
|
|
837
|
+
* WriterLockedError (surfaces as "Cannot resume <id>: session is locked
|
|
838
|
+
* by a live process …"). `knownCwd` skips the header scan for creations,
|
|
839
|
+
* which always target process.cwd().
|
|
840
|
+
*/
|
|
841
|
+
async acquireSessionLock(sessionId, knownCwd) {
|
|
842
|
+
const cwd = knownCwd ?? (await this.headerCwdOf(sessionId));
|
|
843
|
+
if (cwd === undefined || cwd === '') {
|
|
844
|
+
// No trustworthy cwd → the derived path would be a decoy the real
|
|
845
|
+
// writer never touches (fail-open, same policy as the feishu guard):
|
|
846
|
+
// locking a decoy would silently protect nothing while pretending to.
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
const dir = join(sessionLogRoot(), projectKeyFor(cwd), sessionId);
|
|
850
|
+
const result = await acquireWriterLock(dir);
|
|
851
|
+
if (!result.ok)
|
|
852
|
+
throw new WriterLockedError(result.holder);
|
|
853
|
+
this.heldLockDirs.add(dir);
|
|
854
|
+
return { dir };
|
|
855
|
+
}
|
|
856
|
+
/** Undo a guard whose arm then failed — transient errors must not pin the session. */
|
|
857
|
+
async releaseColdLock(lock) {
|
|
858
|
+
if (lock === undefined)
|
|
859
|
+
return;
|
|
860
|
+
this.heldLockDirs.delete(lock.dir);
|
|
861
|
+
await releaseOwnedWriterLock(lock.dir);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Bind lock release to an owned handle's disposal — every teardown path
|
|
865
|
+
* already goes through handle.dispose(), so this is the single choke
|
|
866
|
+
* point; switching away or exiting releases what we established.
|
|
867
|
+
*/
|
|
868
|
+
ownWithLock(handle, lock) {
|
|
869
|
+
if (lock === undefined)
|
|
870
|
+
return handle;
|
|
871
|
+
const base = handle.dispose.bind(handle);
|
|
872
|
+
const bridge = this;
|
|
873
|
+
handle.dispose = async () => {
|
|
874
|
+
try {
|
|
875
|
+
await base();
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
await bridge.releaseColdLock(lock);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
return handle;
|
|
882
|
+
}
|
|
604
883
|
/**
|
|
605
884
|
* Resume a persisted session: tear down the live agent (disposers are kept —
|
|
606
885
|
* the session-id filter re-binds to the resumed id) and load the persisted
|
|
@@ -621,6 +900,7 @@ export class DshSessionBridge {
|
|
|
621
900
|
// touching this field, so it always names the id actually loading.
|
|
622
901
|
this.resumeTargetId = sessionId;
|
|
623
902
|
const task = (async () => {
|
|
903
|
+
await this.stopRemoteWatch();
|
|
624
904
|
const handle = this.handle;
|
|
625
905
|
this.handle = undefined;
|
|
626
906
|
this.sessionId = undefined;
|
|
@@ -666,26 +946,39 @@ export class DshSessionBridge {
|
|
|
666
946
|
}
|
|
667
947
|
return adopted;
|
|
668
948
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
949
|
+
// Cold arm — guarded: without the registry hit above, nothing here can
|
|
950
|
+
// see whether ANOTHER process owns this id right now. Take the writer
|
|
951
|
+
// lock beside its jsonl first so a concurrent owner refuses us loudly
|
|
952
|
+
// instead of two appends interleaving into "corrupt session log".
|
|
953
|
+
const lock = await this.acquireSessionLock(String(sessionId));
|
|
954
|
+
let resumed;
|
|
955
|
+
try {
|
|
956
|
+
resumed = await this.ctx.agents.resume({
|
|
957
|
+
resumeSessionId: sessionId,
|
|
958
|
+
agentOptions: this.selection ?? {},
|
|
959
|
+
// Install the mutable selection so `/model` can live-switch the route,
|
|
960
|
+
// the APPEND_SYSTEM.md section on this agent ONLY (never its
|
|
961
|
+
// subagents), and the spawn-tool hide so the agent sees a single
|
|
962
|
+
// `use_agent` delegation entry. The surface marker scopes the live
|
|
963
|
+
// disableSubagent guard to THIS agent (resume runs setup too, so a
|
|
964
|
+
// resumed session is re-marked without relying on persisted meta).
|
|
965
|
+
setup: async (agentCtx) => {
|
|
966
|
+
markTuiSurface(agentCtx);
|
|
967
|
+
installModelSelection(agentCtx, this.selectionRef);
|
|
968
|
+
installAppendSystem(agentCtx);
|
|
969
|
+
installSpawnToolFence(agentCtx);
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
await this.releaseColdLock(lock);
|
|
975
|
+
throw error;
|
|
976
|
+
}
|
|
977
|
+
this.driveLock = lock;
|
|
978
|
+
this.handle = this.ownWithLock(resumed, lock);
|
|
686
979
|
this.sessionId = sessionId;
|
|
687
980
|
this.trackedSessions.add(String(sessionId));
|
|
688
|
-
return
|
|
981
|
+
return this.handle;
|
|
689
982
|
})();
|
|
690
983
|
this.resuming = task;
|
|
691
984
|
try {
|
|
@@ -1185,27 +1478,41 @@ export class DshSessionBridge {
|
|
|
1185
1478
|
};
|
|
1186
1479
|
}
|
|
1187
1480
|
/** Create the agent with the composed default model selection. */
|
|
1188
|
-
createSession() {
|
|
1481
|
+
async createSession() {
|
|
1189
1482
|
this.seedSelectionFromDefault();
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1483
|
+
// Mint the id HERE so the pre-create lock covers exactly the directory
|
|
1484
|
+
// agents.create is about to persist into (same cold-arm guard as resume).
|
|
1485
|
+
const sessionId = crypto.randomUUID();
|
|
1486
|
+
const cwd = process.cwd();
|
|
1487
|
+
const lock = await this.acquireSessionLock(sessionId, cwd);
|
|
1488
|
+
let handle;
|
|
1489
|
+
try {
|
|
1490
|
+
handle = await this.ctx.agents.create({
|
|
1491
|
+
sessionId: SessionId(sessionId),
|
|
1492
|
+
meta: {
|
|
1493
|
+
cwd,
|
|
1494
|
+
...this.agentPreset !== undefined ? { agentPreset: this.agentPreset } : {},
|
|
1495
|
+
},
|
|
1496
|
+
agentOptions: this.selection ?? {},
|
|
1497
|
+
// Install the mutable selection so `/model` can live-switch the route,
|
|
1498
|
+
// the APPEND_SYSTEM.md section on this agent ONLY (never its
|
|
1499
|
+
// subagents), and the spawn-tool hide so the agent sees a single
|
|
1500
|
+
// `use_agent` delegation entry. The surface marker scopes the live
|
|
1501
|
+
// disableSubagent guard to THIS agent.
|
|
1502
|
+
setup: async (agentCtx) => {
|
|
1503
|
+
markTuiSurface(agentCtx);
|
|
1504
|
+
installModelSelection(agentCtx, this.selectionRef);
|
|
1505
|
+
installAppendSystem(agentCtx);
|
|
1506
|
+
installSpawnToolFence(agentCtx);
|
|
1507
|
+
},
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
catch (error) {
|
|
1511
|
+
await this.releaseColdLock(lock);
|
|
1512
|
+
throw error;
|
|
1513
|
+
}
|
|
1514
|
+
this.driveLock = lock;
|
|
1515
|
+
return this.ownWithLock(handle, lock);
|
|
1209
1516
|
}
|
|
1210
1517
|
/** The model selection shown in the footer (live value after `/model`). */
|
|
1211
1518
|
getSelection() {
|