@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/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
- const resumed = await this.ctx.agents.resume({
670
- resumeSessionId: sessionId,
671
- agentOptions: this.selection ?? {},
672
- // Install the mutable selection so `/model` can live-switch the route,
673
- // the APPEND_SYSTEM.md section on this agent ONLY (never its
674
- // subagents), and the spawn-tool hide so the agent sees a single
675
- // `use_agent` delegation entry. The surface marker scopes the live
676
- // disableSubagent guard to THIS agent (resume runs setup too, so a
677
- // resumed session is re-marked without relying on persisted meta).
678
- setup: async (agentCtx) => {
679
- markTuiSurface(agentCtx);
680
- installModelSelection(agentCtx, this.selectionRef);
681
- installAppendSystem(agentCtx);
682
- installSpawnToolFence(agentCtx);
683
- },
684
- });
685
- this.handle = resumed;
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 resumed;
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
- return this.ctx.agents.create({
1191
- sessionId: SessionId(crypto.randomUUID()),
1192
- meta: {
1193
- cwd: process.cwd(),
1194
- ...this.agentPreset !== undefined ? { agentPreset: this.agentPreset } : {},
1195
- },
1196
- agentOptions: this.selection ?? {},
1197
- // Install the mutable selection so `/model` can live-switch the route,
1198
- // the APPEND_SYSTEM.md section on this agent ONLY (never its
1199
- // subagents), and the spawn-tool hide so the agent sees a single
1200
- // `use_agent` delegation entry. The surface marker scopes the live
1201
- // disableSubagent guard to THIS agent.
1202
- setup: async (agentCtx) => {
1203
- markTuiSurface(agentCtx);
1204
- installModelSelection(agentCtx, this.selectionRef);
1205
- installAppendSystem(agentCtx);
1206
- installSpawnToolFence(agentCtx);
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() {