@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
  3. package/dist/cli.js +3378 -3333
  4. package/dist/types/cli/gc-cli.d.ts +1 -0
  5. package/dist/types/launch/client.d.ts +9 -1
  6. package/dist/types/launch/protocol.d.ts +17 -0
  7. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  8. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +4 -1
  11. package/dist/types/modes/types.d.ts +3 -1
  12. package/dist/types/security/contracts/schemas.d.ts +405 -403
  13. package/dist/types/session/agent-session-types.d.ts +5 -0
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/launch-completion.d.ts +10 -0
  16. package/dist/types/session/session-entries.d.ts +15 -1
  17. package/dist/types/session/session-manager.d.ts +7 -0
  18. package/dist/types/session/yield-queue.d.ts +6 -1
  19. package/dist/types/tools/index.d.ts +9 -0
  20. package/package.json +12 -12
  21. package/src/cli/gc-cli.ts +641 -21
  22. package/src/config/model-discovery.ts +14 -14
  23. package/src/config/model-registry.ts +10 -8
  24. package/src/config/settings.ts +1 -1
  25. package/src/export/html/index.ts +10 -3
  26. package/src/export/share.ts +4 -0
  27. package/src/launch/broker.ts +222 -6
  28. package/src/launch/client.ts +161 -9
  29. package/src/launch/protocol.ts +52 -0
  30. package/src/main.ts +13 -6
  31. package/src/mcp/config-writer.ts +1 -1
  32. package/src/modes/components/btw-panel.ts +41 -4
  33. package/src/modes/controllers/btw-controller.ts +55 -7
  34. package/src/modes/controllers/command-controller.ts +31 -0
  35. package/src/modes/controllers/input-controller.ts +24 -7
  36. package/src/modes/interactive-mode.ts +16 -2
  37. package/src/modes/types.ts +8 -1
  38. package/src/prompts/session/launch-completion.md +1 -0
  39. package/src/sdk.ts +15 -0
  40. package/src/security/contracts/schemas.ts +205 -183
  41. package/src/security/contracts/validation.ts +5 -1
  42. package/src/security/store.ts +2 -2
  43. package/src/session/agent-session-types.ts +6 -0
  44. package/src/session/agent-session.ts +191 -14
  45. package/src/session/launch-completion.ts +37 -0
  46. package/src/session/session-context.ts +26 -0
  47. package/src/session/session-entries.ts +17 -1
  48. package/src/session/session-manager.ts +21 -0
  49. package/src/session/yield-queue.ts +121 -16
  50. package/src/slash-commands/builtin-registry.ts +10 -0
  51. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  52. package/src/tools/hub/launch.ts +122 -6
  53. package/src/tools/index.ts +9 -0
  54. package/dist/types/config/file-lock.d.ts +0 -29
  55. package/src/config/file-lock.ts +0 -164
@@ -144,6 +144,7 @@ import type { GoalModeState } from "../goals/state";
144
144
  import type { HindsightSessionState } from "../hindsight/state";
145
145
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
146
146
  import type { IrcMessage } from "../irc/bus";
147
+ import type { DaemonCompletionNotification } from "../launch/protocol";
147
148
  import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
148
149
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
149
150
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -221,6 +222,7 @@ import type {
221
222
  ModelCycleResult,
222
223
  Prewalk,
223
224
  PromptOptions,
225
+ ResetSessionContextResult,
224
226
  ResolvedRoleModel,
225
227
  RestoredQueuedMessage,
226
228
  RoleModelCycle,
@@ -271,6 +273,12 @@ import {
271
273
  type ToolExecutionStartData,
272
274
  } from "./exit-diagnostics";
273
275
  import { IrcBridge, type IrcBridgeHost } from "./irc-bridge";
276
+ import {
277
+ buildLaunchCompletionBatchMessage,
278
+ isLaunchCompletionOwner,
279
+ LAUNCH_COMPLETION_MESSAGE_TYPE,
280
+ type LaunchCompletionEntry,
281
+ } from "./launch-completion";
274
282
  import {
275
283
  type BashExecutionMessage,
276
284
  buildReplanTitleContext,
@@ -340,6 +348,7 @@ import { TodoTracker, type TodoTrackerHost } from "./todo-tracker";
340
348
  import { TtsrCoordinator, type TtsrCoordinatorHost } from "./ttsr-coordinator";
341
349
 
342
350
  const PLAN_MODE_REMINDER_MAX = 3;
351
+ const POST_PROMPT_DRAIN_TIMEOUT_MS = 5_000;
343
352
 
344
353
  /** Internal marker for hook messages queued through the agent loop */
345
354
  // ============================================================================
@@ -445,6 +454,8 @@ export class AgentSession {
445
454
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
446
455
  #eventListeners: AgentSessionEventListener[] = [];
447
456
  #commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
457
+ #sessionChangeCallbacks = new Set<() => void>();
458
+ #observedSessionId: string | undefined;
448
459
 
449
460
  /** Messages queued to be included with the next user prompt as context ("asides"). */
450
461
  #pendingNextTurnMessages: CustomMessage[] = [];
@@ -658,6 +669,7 @@ export class AgentSession {
658
669
  if (onSettled) this.#inFlightSettledCallbacks.push(onSettled);
659
670
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
660
671
  if (this.#promptInFlightCount !== 0) return;
672
+ this.yieldQueue.requestIdleFlush();
661
673
  this.#releasePowerAssertion();
662
674
  this.#flushPendingAgentEnd();
663
675
  if (this.#inFlightSettledCallbacks.length === 0) {
@@ -839,6 +851,7 @@ export class AgentSession {
839
851
 
840
852
  #resetInFlight(): void {
841
853
  this.#promptInFlightCount = 0;
854
+ this.yieldQueue.requestIdleFlush();
842
855
  this.#releasePowerAssertion();
843
856
  this.#flushPendingAgentEnd();
844
857
  if (this.#inFlightSettledCallbacks.length === 0) {
@@ -1122,17 +1135,30 @@ export class AgentSession {
1122
1135
  injectIdle: async messages => {
1123
1136
  const first = messages[0];
1124
1137
  if (!first) return;
1125
- await this.agent.prompt(messages.length === 1 ? first : messages);
1138
+ this.#beginInFlight();
1139
+ try {
1140
+ await this.agent.prompt(messages.length === 1 ? first : messages);
1141
+ } finally {
1142
+ this.#endInFlight();
1143
+ }
1126
1144
  },
1127
1145
  scheduleIdleFlush: run => {
1128
1146
  this.#schedulePostPromptTask(
1129
1147
  async () => {
1130
1148
  await run();
1131
1149
  },
1132
- { delayMs: 1 },
1150
+ {
1151
+ delayMs: 1,
1152
+ onSkip: () => this.yieldQueue.cancelIdleFlushScheduling(),
1153
+ },
1133
1154
  );
1134
1155
  },
1135
1156
  });
1157
+ this.yieldQueue.register<LaunchCompletionEntry>(LAUNCH_COMPLETION_MESSAGE_TYPE, {
1158
+ isStale: entry =>
1159
+ this.#isDisposed || !isLaunchCompletionOwner(entry.owner, this.sessionManager.getSessionId()),
1160
+ build: buildLaunchCompletionBatchMessage,
1161
+ });
1136
1162
  // Background-job completions / late diagnostics are pulled into the run at
1137
1163
  // each step boundary as non-interrupting asides. Peer IRCs share the aside
1138
1164
  // injection boundary, but also expose a non-consuming interrupt peek so
@@ -2900,6 +2926,7 @@ export class AgentSession {
2900
2926
  try {
2901
2927
  await scheduler.wait(delayMs, { signal });
2902
2928
  } catch {
2929
+ if (signal.aborted) options?.onSkip?.("aborted");
2903
2930
  return;
2904
2931
  }
2905
2932
  }
@@ -3400,6 +3427,12 @@ export class AgentSession {
3400
3427
  };
3401
3428
  }
3402
3429
 
3430
+ /** Register cleanup that runs when this AgentSession adopts a different session ID. */
3431
+ registerSessionChangeCallback(callback: () => void): () => void {
3432
+ this.#sessionChangeCallbacks.add(callback);
3433
+ return () => this.#sessionChangeCallbacks.delete(callback);
3434
+ }
3435
+
3403
3436
  subscribeCommandMetadataChanged(listener: CommandMetadataChangedListener): () => void {
3404
3437
  this.#commandMetadataChangedListeners.push(listener);
3405
3438
  return () => {
@@ -3474,7 +3507,14 @@ export class AgentSession {
3474
3507
  * (login/logout, token refresh that surfaces a new account UUID) without
3475
3508
  * needing to re-call `#syncAgentSessionId()` on every such event.
3476
3509
  */
3477
- #syncAgentSessionId(sessionId?: string): void {
3510
+ #syncAgentSessionId(sessionId?: string, notifyChange = true): void {
3511
+ const currentSessionId = this.sessionManager.getSessionId();
3512
+ if (this.#observedSessionId === undefined) {
3513
+ this.#observedSessionId = currentSessionId;
3514
+ } else if (this.#observedSessionId !== currentSessionId) {
3515
+ this.#observedSessionId = currentSessionId;
3516
+ if (notifyChange) this.#notifySessionChangeCallbacks();
3517
+ }
3478
3518
  const sid = this.#activeProviderSessionId(sessionId);
3479
3519
  this.agent.sessionId = sid;
3480
3520
  this.agent.setMetadataResolver((provider: string) =>
@@ -3496,6 +3536,16 @@ export class AgentSession {
3496
3536
  if (this.#advisors) this.#advisors.refreshProviderIdentity();
3497
3537
  }
3498
3538
 
3539
+ #notifySessionChangeCallbacks(): void {
3540
+ for (const callback of [...this.#sessionChangeCallbacks]) {
3541
+ try {
3542
+ callback();
3543
+ } catch (error) {
3544
+ logger.warn("Session change callback failed", { error: String(error) });
3545
+ }
3546
+ }
3547
+ }
3548
+
3499
3549
  /** Run one abortable auto-learn capture outside the primary agent loop. */
3500
3550
  async runAutolearnCapture(capture: (signal: AbortSignal) => Promise<void>): Promise<void> {
3501
3551
  if (this.#autolearnCaptureTask || this.#isDisposed) return;
@@ -3676,7 +3726,11 @@ export class AgentSession {
3676
3726
  const postPromptDrain = this.#cancelPostPromptTasks();
3677
3727
  this.agent.abort();
3678
3728
  try {
3679
- await withTimeout(postPromptDrain, 5_000, "Timed out draining post-prompt tasks during dispose");
3729
+ await withTimeout(
3730
+ postPromptDrain,
3731
+ POST_PROMPT_DRAIN_TIMEOUT_MS,
3732
+ "Timed out draining post-prompt tasks during dispose",
3733
+ );
3680
3734
  } catch (error) {
3681
3735
  logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
3682
3736
  }
@@ -3723,6 +3777,7 @@ export class AgentSession {
3723
3777
  this.#unsubscribeModelRoles = undefined;
3724
3778
  }
3725
3779
  this.#eventListeners = [];
3780
+ this.#sessionChangeCallbacks.clear();
3726
3781
  }
3727
3782
 
3728
3783
  #closeAllProviderSessions(reason: string): void {
@@ -3757,6 +3812,101 @@ export class AgentSession {
3757
3812
  };
3758
3813
  }
3759
3814
 
3815
+ /**
3816
+ * Reset the current conversation in place: drop every message, queued turn,
3817
+ * and pending tool call from the model's context while keeping the session
3818
+ * itself — its id, title, cwd, model, settings, and on-disk transcript all
3819
+ * survive. The next turn is sent with only the base system prompt plus the
3820
+ * project rules/AGENTS.md.
3821
+ *
3822
+ * This is the in-place sibling of {@link newSession}: it reuses the same
3823
+ * conversation-boundary teardown (drop the conversation, rotate provider-side
3824
+ * session state so providers that keep history server-side resume nothing,
3825
+ * re-prime the advisors, and undo any memory promotion) but skips minting a
3826
+ * new session id and opening a fresh transcript file. Unlike
3827
+ * {@link freshSession} (which only rotates provider stream state) it also
3828
+ * clears the conversation.
3829
+ *
3830
+ * Returns `undefined` without mutating anything while a response is
3831
+ * streaming or a foreground bash/python execution is in flight.
3832
+ */
3833
+ async resetSessionContext(): Promise<ResetSessionContextResult | undefined> {
3834
+ // Refuse while a response streams OR a foreground user bash/python
3835
+ // execution is in flight: those complete via recordBashResult()/
3836
+ // recordPythonResult(), which append directly to agent.state when not
3837
+ // streaming, so a command finishing after the reset would land its output
3838
+ // after the boundary and re-enter the supposedly empty context. The
3839
+ // sibling boundary op (branchFromBtw) guards on the same predicates.
3840
+ if (this.isStreaming || this.isBashRunning || this.isEvalRunning) return undefined;
3841
+ const droppedCount = this.agent.state.messages.length;
3842
+
3843
+ // Tear down the same per-turn runtime state that newSession() resets across
3844
+ // a conversation boundary, so work scheduled from the pre-reset turn cannot
3845
+ // re-enter the cleared context:
3846
+ // - bump #promptGeneration + drain post-prompt tasks so an already-queued
3847
+ // post-prompt continuation (recovery can be scheduled after agent_end
3848
+ // while isStreaming is false) sees a stale generation and skips
3849
+ // (mirrors abort()).
3850
+ // - cancel this agent's async bash/task jobs so their completions can't
3851
+ // re-deliver stale tool output into the cleared conversation
3852
+ // (mirrors newSession()).
3853
+ this.#promptGeneration++;
3854
+ await this.#cancelPostPromptTasks();
3855
+ this.#cancelOwnAsyncJobs();
3856
+
3857
+ // Drop the conversation: messages, queued steers/follow-ups, pending tool
3858
+ // calls, and error state. agent.reset() keeps the model and system prompt.
3859
+ this.agent.reset();
3860
+ this.#pendingNextTurnMessages = [];
3861
+ this.#scheduledHiddenNextTurnGeneration = undefined;
3862
+ // Reset the session_stop continuation chain: the queued continuation
3863
+ // message is gone with the conversation, but the counters would otherwise
3864
+ // carry over, so the next post-reset turn is reported to hooks as part of
3865
+ // the old chain and can hit SESSION_STOP_CONTINUATION_CAP early (mirrors
3866
+ // abort()/newSession()).
3867
+ this.#resetSessionStopContinuationState();
3868
+
3869
+ // Drop checkpoint/rewind runtime state and deferred tool directives
3870
+ // alongside the messages that carried them: the checkpoint tool result is
3871
+ // gone from agent.state, so an intact #checkpointState would otherwise
3872
+ // force a rewind onto the pre-reset transcript on the next turn (mirrors
3873
+ // newSession()).
3874
+ this.#clearCheckpointRuntimeState();
3875
+ this.#clearSessionScopedToolState();
3876
+
3877
+ // Rotate provider-side session state so a provider that keeps conversation
3878
+ // history server-side starts a brand-new exchange rather than resuming the
3879
+ // context we just dropped (mirrors freshSession()).
3880
+ this.#closeAllProviderSessions("reset context");
3881
+ this.#freshProviderSessionId = Bun.randomUUIDv7();
3882
+ this.#syncAgentSessionId();
3883
+ this.#memory.rekeyForCurrentSessionId();
3884
+ this.agent.appendOnlyContext?.invalidateForModelChange();
3885
+
3886
+ // Re-arm the approved-plan reference: the reset dropped the plan-approved
3887
+ // prompt/reference from agent.state, so mark it unsent (preserving the
3888
+ // path — the plan file on disk is still the active plan) to let
3889
+ // #buildPlanReferenceMessage re-read and re-inject it on the next turn.
3890
+ // Mirrors the sent-flag reset newSession() and compaction perform after a
3891
+ // history rewrite (issue #1246).
3892
+ this.#planReferenceSent = false;
3893
+
3894
+ // Re-prime the advisors across the conversation boundary and undo any
3895
+ // memory promotion so the next turn rebuilds from the base system prompt.
3896
+ this.#advisors.resetSessionState();
3897
+ await this.#memory.resetContextForNewTranscript();
3898
+
3899
+ // Record a durable boundary on the persisted branch. The collapsed live
3900
+ // transcript and the model-context rebuild start emission after the latest
3901
+ // boundary, so a rebuild across a `/reset` (theme change, focus attach,
3902
+ // /shake, resume) does not resurrect the pre-reset conversation. The
3903
+ // on-disk record and the plain `transcript:true` export path keep the full
3904
+ // pre-reset history.
3905
+ this.sessionManager.appendResetBoundary();
3906
+
3907
+ return { droppedCount };
3908
+ }
3909
+
3760
3910
  // =========================================================================
3761
3911
  // Read-only State Access
3762
3912
  // =========================================================================
@@ -5534,6 +5684,16 @@ export class AgentSession {
5534
5684
  this.#queueHiddenNextTurnMessage(message, true);
5535
5685
  }
5536
5686
 
5687
+ queueLaunchCompletion(notification: DaemonCompletionNotification): Promise<void> {
5688
+ if (this.#isDisposed) return Promise.reject(new Error("Session disposed before launch completion delivery"));
5689
+ const delivered = this.yieldQueue.enqueueWithReceipt<LaunchCompletionEntry>(
5690
+ LAUNCH_COMPLETION_MESSAGE_TYPE,
5691
+ notification,
5692
+ );
5693
+ this.yieldQueue.requestIdleFlush();
5694
+ return delivered;
5695
+ }
5696
+
5537
5697
  #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
5538
5698
  this.#pendingNextTurnMessages.push(message);
5539
5699
  if (!triggerTurn) return;
@@ -7251,7 +7411,7 @@ export class AgentSession {
7251
7411
  this.#clearInheritedProviderPromptCacheKey();
7252
7412
  this.#adoptInheritedProviderPromptCacheKey();
7253
7413
  }
7254
- this.#syncAgentSessionId();
7414
+ this.#syncAgentSessionId(undefined, false);
7255
7415
  this.#memory.rekeyForCurrentSessionId();
7256
7416
 
7257
7417
  let sessionContext = this.buildDisplaySessionContext();
@@ -7388,11 +7548,14 @@ export class AgentSession {
7388
7548
  this.#advisors.restoreCost(await loadAdvisorTranscriptCosts(this.sessionFile));
7389
7549
  }
7390
7550
  this.#bash.finishSessionTransition(bashTransition, true);
7551
+ if (previousSessionState.sessionId !== this.sessionManager.getSessionId()) {
7552
+ this.#notifySessionChangeCallbacks();
7553
+ }
7391
7554
  return true;
7392
7555
  } catch (error) {
7393
7556
  this.sessionManager.restoreState(previousSessionState);
7394
7557
  this.#freshProviderSessionId = previousFreshProviderSessionId;
7395
- this.#syncAgentSessionId(previousSessionState.sessionId);
7558
+ this.#syncAgentSessionId(previousSessionState.sessionId, false);
7396
7559
  this.#memory.rekeyForCurrentSessionId();
7397
7560
  this.agent.setTools(previousTools);
7398
7561
  this.#tools.setBaseSystemPrompt(previousBaseSystemPrompt);
@@ -7506,7 +7669,10 @@ export class AgentSession {
7506
7669
  await this.#advisors.drainAndDetachRecorders();
7507
7670
  try {
7508
7671
  if (!selectedEntry.parentId) {
7672
+ const title = this.sessionManager.getSessionName();
7673
+ const titleSource = this.sessionManager.titleSource;
7509
7674
  await this.sessionManager.newSession({ parentSession: previousSessionFile });
7675
+ if (title) await this.sessionManager.setSessionName(title, titleSource);
7510
7676
  } else {
7511
7677
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
7512
7678
  }
@@ -7553,21 +7719,24 @@ export class AgentSession {
7553
7719
  }
7554
7720
  }
7555
7721
 
7722
+ /** Promotes a completed /btw answer from the explicitly authorized session and leaf. */
7556
7723
  async branchFromBtw(
7557
7724
  question: string,
7558
7725
  assistantMessage: AssistantMessage,
7726
+ leafId: string,
7727
+ sessionId: string,
7559
7728
  ): Promise<{ cancelled: boolean; sessionFile: string | undefined }> {
7560
7729
  const previousSessionFile = this.sessionFile;
7561
7730
  if (!this.sessionManager.getSessionFile()) {
7562
7731
  throw new Error("Cannot branch /btw: session is not persisted");
7563
7732
  }
7564
7733
 
7565
- const leafId = this.sessionManager.getLeafId();
7566
- if (!leafId) {
7567
- throw new Error("Cannot branch /btw: current session has no leaf");
7734
+ if (!leafId || this.sessionManager.getSessionId() !== sessionId || this.sessionManager.getLeafId() !== leafId) {
7735
+ throw new Error("Cannot branch /btw: session changed since /btw started");
7568
7736
  }
7569
7737
 
7570
7738
  if (
7739
+ this.isStreaming ||
7571
7740
  this.isBashRunning ||
7572
7741
  this.isEvalRunning ||
7573
7742
  this.isCompacting ||
@@ -7588,8 +7757,17 @@ export class AgentSession {
7588
7757
  }
7589
7758
  }
7590
7759
 
7591
- await this.#cancelPostPromptTasks();
7760
+ if (this.sessionManager.getSessionId() !== sessionId || this.sessionManager.getLeafId() !== leafId) {
7761
+ throw new Error("Cannot branch /btw: session changed since /btw started");
7762
+ }
7763
+
7764
+ await withTimeout(
7765
+ this.#cancelPostPromptTasks(),
7766
+ POST_PROMPT_DRAIN_TIMEOUT_MS,
7767
+ "Timed out draining post-prompt tasks before /btw branch",
7768
+ );
7592
7769
  if (
7770
+ this.isStreaming ||
7593
7771
  this.isBashRunning ||
7594
7772
  this.isEvalRunning ||
7595
7773
  this.isCompacting ||
@@ -7602,10 +7780,6 @@ export class AgentSession {
7602
7780
  this.#pendingNextTurnMessages = [];
7603
7781
  this.#scheduledHiddenNextTurnGeneration = undefined;
7604
7782
  this.agent.replaceQueues([], []);
7605
- if (this.isStreaming) {
7606
- await this.abort({ goalReason: "internal", reason: "branching /btw" });
7607
- this.agent.replaceQueues([], []);
7608
- }
7609
7783
  await this.#bash.flushPending();
7610
7784
  await this.sessionManager.flush();
7611
7785
  const bashTransition = this.#bash.beginSessionTransition();
@@ -7619,6 +7793,9 @@ export class AgentSession {
7619
7793
  advisorRecordersDetached = true;
7620
7794
  await this.#advisors.drainAndDetachRecorders();
7621
7795
  try {
7796
+ if (this.sessionManager.getSessionId() !== sessionId || this.sessionManager.getLeafId() !== leafId) {
7797
+ throw new Error("Cannot branch /btw: session changed since /btw started");
7798
+ }
7622
7799
  this.sessionManager.createBranchedSession(leafId);
7623
7800
  this.#bash.markSessionTransition(bashTransition);
7624
7801
  this.#advisors.clearCost();
@@ -0,0 +1,37 @@
1
+ import { prompt } from "@oh-my-pi/pi-utils";
2
+ import type { DaemonCompletionNotification } from "../launch/protocol";
3
+ import launchCompletionTemplate from "../prompts/session/launch-completion.md" with { type: "text" };
4
+ import type { CustomMessage } from "./messages";
5
+
6
+ /** Yield-queue kind for broker-owned supervised process completions. */
7
+ export const LAUNCH_COMPLETION_MESSAGE_TYPE = "launch-completion";
8
+
9
+ /** One broker completion awaiting injection into its owning session. */
10
+ export type LaunchCompletionEntry = DaemonCompletionNotification;
11
+
12
+ /** Whether a broker completion belongs to the primary session or its advisor. */
13
+ export function isLaunchCompletionOwner(owner: string, sessionId: string): boolean {
14
+ return owner === sessionId || owner === `${sessionId}-advisor`;
15
+ }
16
+
17
+ /** Build one model-visible notification per terminal supervised process exit. */
18
+ export function buildLaunchCompletionBatchMessage(entries: LaunchCompletionEntry[]): CustomMessage {
19
+ return {
20
+ role: "custom",
21
+ customType: LAUNCH_COMPLETION_MESSAGE_TYPE,
22
+ content: entries
23
+ .map(({ daemon }) =>
24
+ prompt.render(launchCompletionTemplate, {
25
+ name: daemon.name,
26
+ state: daemon.state,
27
+ exitCode: daemon.exitCode,
28
+ hasExitCode: daemon.exitCode !== undefined,
29
+ }),
30
+ )
31
+ .join("\n"),
32
+ display: true,
33
+ attribution: "agent",
34
+ details: { daemons: entries.map(entry => entry.daemon) },
35
+ timestamp: Date.now(),
36
+ };
37
+ }
@@ -284,6 +284,12 @@ export function buildSessionContext(
284
284
 
285
285
  const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
286
286
 
287
+ // Index on the path of the latest `/reset` boundary, or -1 when none. The
288
+ // collapsed live transcript and the model-context rebuild start emission
289
+ // after it (see the emission branch below); the full-history export path
290
+ // ignores it.
291
+ const resetBoundaryIdx = path.reduce((latest, entry, i) => (entry.type === "reset_boundary" ? i : latest), -1);
292
+
287
293
  // Build messages and collect corresponding entries
288
294
  // When there's a compaction, we need to:
289
295
  // 1. Emit summary first (entry = compaction)
@@ -379,6 +385,26 @@ export function buildSessionContext(
379
385
  appendMessage(entry);
380
386
  }
381
387
  }
388
+ } else if (
389
+ resetBoundaryIdx >= 0 &&
390
+ resetBoundaryIdx > (compaction ? path.findIndex(e => e.type === "compaction" && e.id === compaction.id) : -1)
391
+ ) {
392
+ // A `/reset` boundary durably starts emission after it — for BOTH the
393
+ // collapsed live transcript AND the model context (non-transcript) rebuild
394
+ // that feeds agent.replaceMessages (resume, /shake, reload, image drop).
395
+ // Without honoring it here, those model-context rebuilds walk the full
396
+ // persisted branch and put the pre-reset turns back into the LLM context
397
+ // even though `/reset` reported it empty. The full-history export path
398
+ // (`transcript && !collapseCompactedHistory`) is handled by the first
399
+ // branch above and left untouched, so on-disk history stays recoverable.
400
+ // When a compaction and a reset boundary interact, the later one on the
401
+ // path wins: a boundary after the latest compaction elides that compaction
402
+ // (and its kept tail) too, so only genuinely post-reset entries emit; a
403
+ // boundary before the latest compaction is superseded by it (the
404
+ // `else if (compaction)` branch below handles that case via this guard).
405
+ for (let i = resetBoundaryIdx + 1; i < path.length; i++) {
406
+ appendMessage(path[i]);
407
+ }
382
408
  } else if (compaction) {
383
409
  const providerPayload = getOpenAiRemoteCompactionPayload(compaction);
384
410
  const remoteReplacementHistory = providerPayload?.items;
@@ -39,6 +39,8 @@ export interface SessionHeader {
39
39
  */
40
40
  additionalDirectories?: string[];
41
41
  parentSession?: string;
42
+ /** Prior absolute JSONL locations recorded by successful session moves. */
43
+ previousSessionFiles?: string[];
42
44
  /** Provider prompt-cache identity inherited by exact-route full forks. */
43
45
  providerPromptCacheKey?: string;
44
46
  }
@@ -119,6 +121,18 @@ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
119
121
  fromExtension?: boolean;
120
122
  }
121
123
 
124
+ /**
125
+ * Pure marker entry recorded by `/reset` (resetSessionContext). It carries no
126
+ * payload — its presence on the branch is a durable boundary the collapsed
127
+ * live transcript and the model-context rebuild start emission after, so a
128
+ * rebuild (theme change, focus attach, /shake, resume) does not resurrect the
129
+ * pre-reset conversation. The on-disk record and the plain `transcript:true`
130
+ * export path keep the full pre-reset history.
131
+ */
132
+ export interface ResetBoundaryEntry extends SessionEntryBase {
133
+ type: "reset_boundary";
134
+ }
135
+
122
136
  /**
123
137
  * Custom entry for extensions to store extension-specific data in the session.
124
138
  * Use customType to identify your extension's entries.
@@ -155,6 +169,7 @@ declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
155
169
  interface CustomCompactionSessionEntries {
156
170
  titleChange: TitleChangeEntry;
157
171
  credentialPin: CredentialPinEntry;
172
+ resetBoundary: ResetBoundaryEntry;
158
173
  }
159
174
  }
160
175
 
@@ -250,7 +265,8 @@ export type SessionEntry =
250
265
  | TtsrInjectionEntry
251
266
  | SessionInitEntry
252
267
  | ModeChangeEntry
253
- | CredentialPinEntry;
268
+ | CredentialPinEntry
269
+ | ResetBoundaryEntry;
254
270
 
255
271
  /** Raw logical file entry after loaders strip any fixed-width title slot. */
256
272
  export type FileEntry = SessionHeader | SessionEntry;
@@ -44,6 +44,7 @@ import {
44
44
  type ModeChangeEntry,
45
45
  type ModelChangeEntry,
46
46
  type NewSessionOptions,
47
+ type ResetBoundaryEntry,
47
48
  type ServiceTierChangeEntry,
48
49
  type SessionEntry,
49
50
  type SessionHeader,
@@ -1465,6 +1466,12 @@ export class SessionManager {
1465
1466
  throw err;
1466
1467
  }
1467
1468
 
1469
+ if (sessionFileExisted && sessionPathChanged) {
1470
+ this.#header.previousSessionFiles = [
1471
+ ...new Set([...(this.#header.previousSessionFiles ?? []), path.resolve(oldSessionFile)]),
1472
+ ];
1473
+ }
1474
+
1468
1475
  this.#sessionFile = newSessionFile;
1469
1476
  this.#artifactManager = null;
1470
1477
  this.#artifactManagerSessionFile = null;
@@ -2086,6 +2093,18 @@ export class SessionManager {
2086
2093
  return entry.id;
2087
2094
  }
2088
2095
 
2096
+ /**
2097
+ * Append the durable conversation boundary recorded by `/reset`. The
2098
+ * collapsed live transcript and the model-context rebuild start after the
2099
+ * latest one, while the full history stays on disk (the plain
2100
+ * `transcript:true` export walks it unchanged).
2101
+ */
2102
+ appendResetBoundary(): string {
2103
+ const entry: ResetBoundaryEntry = { type: "reset_boundary", ...this.#freshEntryFields() };
2104
+ this.#recordEntry(entry);
2105
+ return entry.id;
2106
+ }
2107
+
2089
2108
  appendCustomEntry(customType: string, data?: unknown): string {
2090
2109
  const entry: CustomEntry = { type: "custom", customType, data, ...this.#freshEntryFields() };
2091
2110
  this.#recordEntry(entry);
@@ -2340,6 +2359,8 @@ export class SessionManager {
2340
2359
  id: newSessionId,
2341
2360
  timestamp,
2342
2361
  cwd: this.#cwd,
2362
+ title: this.#sessionName,
2363
+ titleSource: this.#titleSource,
2343
2364
  parentSession: this.#persist ? sourceSessionFile : undefined,
2344
2365
  additionalDirectories: this.#additionalDirectories.length > 0 ? [...this.#additionalDirectories] : undefined,
2345
2366
  };