@gkzhb/pi-roles 0.2.4-beta.1 → 0.2.4-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,7 +186,11 @@ By default, a normal new conversation resolves its role again from `--role`, `PI
186
186
  { "pi-roles": { "preserveRoleOnNewSession": true } }
187
187
  ```
188
188
 
189
- This applies only to ordinary new conversations. `/role <name> --reset` still uses its explicitly requested role; reload and resume restore their session role as before. The setting does not persist a role across a Pi restart—on startup, the normal initial-role resolution still applies.
189
+ A normal replacement reads the old session file. **Before the first assistant
190
+ response, Pi deliberately has not created that file yet**; during that narrow
191
+ case pi-roles transfers the active role through a process-local snapshot taken
192
+ at `session_before_switch`. The replacement instance consumes that snapshot
193
+ once. This fallback does not persist roles across a Pi restart.
190
194
 
191
195
  ---
192
196
 
@@ -295,7 +299,7 @@ If you have Pi's [auto-reload](https://github.com/badlogic/pi-mono/blob/main/pac
295
299
  |---|---|---|
296
300
  | `roleScope` | `"both"` | Discovery scope. `"user"`, `"project"`, or `"both"`. |
297
301
  | `defaultRole` | `"role-assistant"` | Role applied at session start when no `--role` or `PI_ROLE`. |
298
- | `preserveRoleOnNewSession` | `false` | Keep the current process's active role when creating a normal new conversation. Does not apply across restarts or to explicit `/role <name> --reset`. |
302
+ | `preserveRoleOnNewSession` | `false` | Keep the prior session's active role when creating a normal new conversation. Does not apply across restarts or to explicit `/role <name> --reset`. |
299
303
  | `intercomMode` | `"off"` | Default intercom behavior for roles that don't set it explicitly. |
300
304
  | `titleModel` | `null` (auto) | Model used for session-intent summarization. Its API key and request headers are resolved from Pi's model registry, including `models.json` environment-variable configuration. Falls back to the session's current model. |
301
305
  | `warnOnMissingMcp` | `true` | Whether to surface a warning when a role's `mcp:*` entry can't be resolved. |
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ExtensionAPI } from '@mariozechner/pi-coding-agent';
1
+ import { ExtensionAPI, SessionEntry } from '@mariozechner/pi-coding-agent';
2
2
  import { AutocompleteItem } from '@mariozechner/pi-tui';
3
3
  import { Static, Type } from 'typebox';
4
4
 
@@ -192,6 +192,50 @@ declare const PiRolesSettingsSchema: Type.TObject<{
192
192
  warnOnMissingMcp: Type.TOptional<Type.TBoolean>;
193
193
  }>;
194
194
  type PiRolesSettings = Static<typeof PiRolesSettingsSchema>;
195
+ /**
196
+ * State persisted via `pi.appendEntry("pi-roles:active-role", ...)`. Read on
197
+ * `session_start` (with reason="reload" or "resume") to restore the active
198
+ * role after a reload, since extension memory is wiped on /reload.
199
+ *
200
+ * `appliedAt` is the leaf role's source/path so we can re-resolve the chain
201
+ * fresh on restore — the extends parents may have been edited in the
202
+ * meantime.
203
+ *
204
+ * `intent` carries the session-intent summary we generated for the title, so
205
+ * a /role swap mid-session can keep the intent and just replace the role
206
+ * prefix in the session name without re-summarizing.
207
+ */
208
+ declare const ActiveRoleStateSchema: Type.TObject<{
209
+ name: Type.TString;
210
+ /** Source of the LEAF role file when it was applied. */
211
+ source: Type.TUnion<[Type.TLiteral<"project">, Type.TLiteral<"user">, Type.TLiteral<"built-in">]>;
212
+ /** Path of the LEAF role file. */
213
+ path: Type.TString;
214
+ /** Cached session-intent summary for the title. May be empty pre-first-message. */
215
+ intent: Type.TOptional<Type.TString>;
216
+ /** Unix ms timestamp; for diagnostics only. */
217
+ appliedAt: Type.TNumber;
218
+ }>;
219
+ type ActiveRoleState = Static<typeof ActiveRoleStateSchema>;
220
+ interface ResetRoleRequest {
221
+ /** Name explicitly requested by `/role <name> --reset`. */
222
+ name: string;
223
+ /** Unix ms timestamp, retained for diagnostics. */
224
+ requestedAt: number;
225
+ }
226
+
227
+ /**
228
+ * State a replacement session may inherit from the previous session.
229
+ *
230
+ * Persisted session entries are authoritative. This process-local state only
231
+ * bridges Pi's pre-first-assistant-response gap, during which the old session
232
+ * has entries in memory but no session file on disk yet.
233
+ */
234
+ interface PreviousSessionRoleState {
235
+ activeRole: ActiveRoleState | undefined;
236
+ /** Present only when the final reset lifecycle event is a valid request. */
237
+ pendingResetRole: ResetRoleRequest | undefined;
238
+ }
195
239
 
196
240
  /**
197
241
  * pi-roles extension entry point.
@@ -200,12 +244,13 @@ type PiRolesSettings = Static<typeof PiRolesSettingsSchema>;
200
244
  * (settings.ts) into the three Pi integration points the role lifecycle
201
245
  * actually needs:
202
246
  *
203
- * - `session_start` — restore from persisted state on reload/resume,
204
- * otherwise resolve a role name from the precedence chain (pendingReset
205
- * > --role > PI_ROLE > settings.defaultRole > built-in role-assistant),
206
- * then apply it. When `preserveRoleOnNewSession` is enabled, a normal new
207
- * session instead retains the role currently active in this extension
208
- * instance.
247
+ * - `session_start` — restore the current session's persisted role on
248
+ * reload/resume. For a new session, resolve an explicit persisted reset
249
+ * request first, then (when configured) preserve the previous session's
250
+ * active role, otherwise use --role > PI_ROLE > defaultRole > built-in.
251
+ * New-session transfer reads `previousSessionFile`; Pi recreates the
252
+ * extension instance during session replacement, so in-memory state is
253
+ * intentionally never used as a cross-session handoff.
209
254
  * - `before_agent_start` — re-inject the active role's body as the system
210
255
  * prompt every turn (Pi rebuilds the prompt per turn; this is the
211
256
  * stable hook).
@@ -221,8 +266,12 @@ type PiRolesSettings = Static<typeof PiRolesSettingsSchema>;
221
266
  interface RuntimeState {
222
267
  /** Live role applied to this session, or null before first apply. */
223
268
  activeRole: ResolvedRole | null;
224
- /** Set by `/role <name> --reset` so the next session_start (reason="new") applies it. */
225
- pendingRoleAfterReset: string | null;
269
+ /**
270
+ * Explicit reset requested in this still-live instance. It is copied into
271
+ * the process bridge by session_before_switch, then read by the replacement
272
+ * instance if Pi has not yet created the old session file.
273
+ */
274
+ pendingResetRequest: ResetRoleRequest | undefined;
226
275
  /** Cached discovery result; refreshed on session_start, every `/role` invocation, and `/role reload`. */
227
276
  roles: RawRole[];
228
277
  /** Shadowed roles found at lower-precedence scopes; shown in `/role list`. */
@@ -265,15 +314,15 @@ declare function composeSystemPrompt(state: Pick<RuntimeState, "activeRole" | "s
265
314
  systemPrompt: string;
266
315
  } | undefined;
267
316
  /**
268
- * Pick the role for an ordinary new conversation. When configured, retain the
269
- * role active in this extension instance; otherwise use normal initial-role
270
- * resolution. An explicit --reset role is handled by session_start before
271
- * this helper is reached.
317
+ * Pick the role for an ordinary new conversation. `activeRole` must come
318
+ * from the previous session's persisted state, not the current extension
319
+ * instance: Pi recreates extensions for `/new`. An explicit --reset role is
320
+ * resolved before this helper is reached.
272
321
  */
273
322
  declare function pickNewSessionRoleName(activeRole: Pick<ResolvedRole, "name"> | null, pi: ExtensionAPI, settings: PiRolesSettings, roles: RawRole[]): string;
274
323
  /**
275
- * Pick the role to launch with on a fresh session_start (no pendingReset, no
276
- * persisted state to restore). Precedence per BUILD-STATUS.md:
324
+ * Pick the role to launch with on a fresh session_start (no persisted
325
+ * previous-session override). Precedence per BUILD-STATUS.md:
277
326
  *
278
327
  * --role flag > PI_ROLE env > settings.defaultRole > built-in role-assistant
279
328
  *
@@ -282,10 +331,32 @@ declare function pickNewSessionRoleName(activeRole: Pick<ResolvedRole, "name"> |
282
331
  * of the session.
283
332
  */
284
333
  declare function pickInitialRoleName(pi: ExtensionAPI, settings: PiRolesSettings, roles: RawRole[]): string;
334
+ /**
335
+ * Prefer durable state from the previous session log. The process-local
336
+ * transfer is only a fallback for Pi's pre-first-response persistence gap.
337
+ */
338
+ declare function pickPreviousSessionRoleState(diskState: PreviousSessionRoleState | undefined, processState: PreviousSessionRoleState | undefined): PreviousSessionRoleState | undefined;
339
+ /**
340
+ * Read transferable state from an earlier session file. Session files may be
341
+ * missing, corrupted, or unavailable in ephemeral modes; those cases simply
342
+ * disable inheritance and fall back to ordinary initial resolution.
343
+ */
344
+ declare function findPreviousSessionRoleState(previousSessionFile: string | undefined): PreviousSessionRoleState | undefined;
345
+ /**
346
+ * Interpret entries from a previous session for a fresh `reason="new"`
347
+ * session. Exported as a pure test seam: the runtime reader above only opens
348
+ * the file, while this function contains the lifecycle semantics.
349
+ *
350
+ * Reset events are intentionally an ordered two-event protocol without a
351
+ * requestId. The final reset lifecycle event is authoritative: cancellation
352
+ * suppresses every earlier request; a valid final request overrides ordinary
353
+ * role preservation.
354
+ */
355
+ declare function resolvePreviousSessionRoleState(entries: readonly SessionEntry[]): PreviousSessionRoleState;
285
356
  /**
286
357
  * Provide tab completions for `/role <here>`. Combines built-in subcommands
287
358
  * with discovered role names; case-insensitive prefix match.
288
359
  */
289
360
  declare function roleCompletions(prefix: string, roles: RawRole[]): AutocompleteItem[] | null;
290
361
 
291
- export { composeSystemPrompt, export_default as default, pickInitialRoleName, pickNewSessionRoleName, roleCompletions };
362
+ export { composeSystemPrompt, export_default as default, findPreviousSessionRoleState, pickInitialRoleName, pickNewSessionRoleName, pickPreviousSessionRoleState, resolvePreviousSessionRoleState, roleCompletions };
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ // src/index.ts
2
+ import {
3
+ SessionManager
4
+ } from "@mariozechner/pi-coding-agent";
5
+
1
6
  // src/schemas.ts
2
7
  import { Type } from "typebox";
3
8
  var ThinkingLevelSchema = Type.Union(
@@ -120,6 +125,8 @@ var ActiveRoleStateSchema = Type.Object(
120
125
  { additionalProperties: false }
121
126
  );
122
127
  var ACTIVE_ROLE_ENTRY_TYPE = "pi-roles:active-role";
128
+ var RESET_ROLE_REQUEST_ENTRY_TYPE = "pi-roles:reset-role-request";
129
+ var RESET_ROLE_CANCELLED_ENTRY_TYPE = "pi-roles:reset-role-cancelled";
123
130
  var ROLE_NOTIFICATION_MESSAGE_TYPE = "pi-roles:notification";
124
131
  var STATUS_KEY = "pi-roles";
125
132
  var BUILTIN_ROLE_ASSISTANT_NAME = "role-assistant";
@@ -712,6 +719,26 @@ async function generateAndApplyTitle(args) {
712
719
  }
713
720
  }
714
721
 
722
+ // src/utils/process-transfer.ts
723
+ var PROCESS_TRANSFER_KEY = /* @__PURE__ */ Symbol.for("pi-roles.previous-session-transfer");
724
+ function getStore() {
725
+ const root = globalThis;
726
+ return root[PROCESS_TRANSFER_KEY] ??= /* @__PURE__ */ new Map();
727
+ }
728
+ function storeProcessTransfer(sessionFile, state) {
729
+ if (sessionFile) getStore().set(sessionFile, state);
730
+ }
731
+ function takeProcessTransfer(sessionFile) {
732
+ if (!sessionFile) return void 0;
733
+ const store = getStore();
734
+ const state = store.get(sessionFile);
735
+ store.delete(sessionFile);
736
+ return state;
737
+ }
738
+ function discardProcessTransfer(sessionFile) {
739
+ if (sessionFile) getStore().delete(sessionFile);
740
+ }
741
+
715
742
  // src/index.ts
716
743
  var FLAG_NAME = "role";
717
744
  var ENV_VAR = "PI_ROLE";
@@ -719,7 +746,7 @@ var SUBCOMMANDS = ["list", "current", "reload"];
719
746
  function index_default(pi) {
720
747
  const state = {
721
748
  activeRole: null,
722
- pendingRoleAfterReset: null,
749
+ pendingResetRequest: void 0,
723
750
  roles: [],
724
751
  shadowed: [],
725
752
  settings: {},
@@ -740,22 +767,42 @@ function index_default(pi) {
740
767
  pi.registerMessageRenderer(ROLE_NOTIFICATION_MESSAGE_TYPE, () => {
741
768
  return void 0;
742
769
  });
770
+ pi.on("session_before_switch", (event, ctx) => {
771
+ if (event.reason !== "new") return;
772
+ storeProcessTransfer(ctx.sessionManager.getSessionFile(), {
773
+ activeRole: state.activeRole ? activeRoleStateFromResolved(state.activeRole, state.intent) : void 0,
774
+ pendingResetRole: state.pendingResetRequest
775
+ });
776
+ });
743
777
  pi.on("session_start", async (event, ctx) => {
744
778
  refreshFromDisk(ctx.cwd);
745
779
  const restored = findRestoredState(ctx);
746
- debugLog("index", `session_start reason=${event.reason}`, restored ? { name: restored.name, intent: restored.intent } : void 0);
780
+ const diskPrevious = event.reason === "new" ? findPreviousSessionRoleState(event.previousSessionFile) : void 0;
781
+ const memoryPrevious = event.reason === "new" ? takeProcessTransfer(event.previousSessionFile) : void 0;
782
+ const previous = pickPreviousSessionRoleState(diskPrevious, memoryPrevious);
783
+ debugLog("index", `session_start reason=${event.reason}`, {
784
+ restored: restored ? { name: restored.name, intent: restored.intent } : void 0,
785
+ previousActiveRole: previous?.activeRole?.name,
786
+ previousResetRole: previous?.pendingResetRole?.name
787
+ });
747
788
  let targetName;
748
789
  let preservedIntent;
749
790
  let silent = false;
750
- if (state.pendingRoleAfterReset) {
751
- targetName = state.pendingRoleAfterReset;
752
- state.pendingRoleAfterReset = null;
753
- } else if ((event.reason === "reload" || event.reason === "resume") && restored) {
791
+ if ((event.reason === "reload" || event.reason === "resume") && restored) {
754
792
  targetName = restored.name;
755
793
  preservedIntent = restored.intent;
756
794
  silent = true;
795
+ } else if (event.reason === "new" && previous?.pendingResetRole) {
796
+ targetName = previous.pendingResetRole.name;
797
+ } else if (event.reason === "new") {
798
+ targetName = pickNewSessionRoleName(
799
+ previous?.activeRole ?? null,
800
+ pi,
801
+ state.settings,
802
+ state.roles
803
+ );
757
804
  } else {
758
- targetName = event.reason === "new" ? pickNewSessionRoleName(state.activeRole, pi, state.settings, state.roles) : pickInitialRoleName(pi, state.settings, state.roles);
805
+ targetName = pickInitialRoleName(pi, state.settings, state.roles);
759
806
  silent = event.reason === "startup";
760
807
  }
761
808
  state.intent = preservedIntent;
@@ -800,10 +847,14 @@ function index_default(pi) {
800
847
  const wantsReset = tokens.includes("--reset");
801
848
  const name = sub;
802
849
  if (wantsReset) {
803
- state.pendingRoleAfterReset = name;
850
+ const request = { name, requestedAt: Date.now() };
851
+ state.pendingResetRequest = request;
852
+ pi.appendEntry(RESET_ROLE_REQUEST_ENTRY_TYPE, request);
804
853
  const result = await resetSession(ctx);
805
854
  if (result.cancelled) {
806
- state.pendingRoleAfterReset = null;
855
+ state.pendingResetRequest = void 0;
856
+ discardProcessTransfer(ctx.sessionManager.getSessionFile());
857
+ pi.appendEntry(RESET_ROLE_CANCELLED_ENTRY_TYPE, { cancelledAt: Date.now() });
807
858
  ctx.ui.notify(`Role switch to "${name}" cancelled.`, "info");
808
859
  }
809
860
  return;
@@ -843,14 +894,72 @@ function findRestoredState(ctx) {
843
894
  } catch {
844
895
  return void 0;
845
896
  }
897
+ return findActiveRoleState(entries);
898
+ }
899
+ function pickPreviousSessionRoleState(diskState, processState) {
900
+ return hasPreviousRoleState(diskState) ? diskState : processState ?? diskState;
901
+ }
902
+ function hasPreviousRoleState(state) {
903
+ return !!state && (!!state.activeRole || !!state.pendingResetRole);
904
+ }
905
+ function activeRoleStateFromResolved(role, intent) {
906
+ return {
907
+ name: role.name,
908
+ source: role.source,
909
+ path: role.path,
910
+ intent,
911
+ appliedAt: Date.now()
912
+ };
913
+ }
914
+ function findPreviousSessionRoleState(previousSessionFile) {
915
+ if (!previousSessionFile) return void 0;
916
+ try {
917
+ return resolvePreviousSessionRoleState(SessionManager.open(previousSessionFile).getEntries());
918
+ } catch (err) {
919
+ debugLog("index", `could not read previous session ${previousSessionFile}`, String(err));
920
+ return void 0;
921
+ }
922
+ }
923
+ function resolvePreviousSessionRoleState(entries) {
924
+ let pendingResetRole;
846
925
  for (let i = entries.length - 1; i >= 0; i--) {
847
- const e = entries[i];
848
- if (e && e.type === "custom" && e.customType === ACTIVE_ROLE_ENTRY_TYPE) {
849
- return e.data ?? void 0;
926
+ const entry = entries[i];
927
+ if (!entry || entry.type !== "custom") continue;
928
+ if (entry.customType === RESET_ROLE_CANCELLED_ENTRY_TYPE) break;
929
+ if (entry.customType === RESET_ROLE_REQUEST_ENTRY_TYPE) {
930
+ const request = asResetRoleRequest(entry.data);
931
+ if (request) pendingResetRole = request;
932
+ break;
933
+ }
934
+ }
935
+ return { activeRole: findActiveRoleState(entries), pendingResetRole };
936
+ }
937
+ function findActiveRoleState(entries) {
938
+ for (let i = entries.length - 1; i >= 0; i--) {
939
+ const entry = entries[i];
940
+ if (entry?.type === "custom" && entry.customType === ACTIVE_ROLE_ENTRY_TYPE) {
941
+ const state = asActiveRoleState(entry.data);
942
+ if (state) return state;
850
943
  }
851
944
  }
852
945
  return void 0;
853
946
  }
947
+ function asActiveRoleState(data) {
948
+ if (!data || typeof data !== "object") return void 0;
949
+ const candidate = data;
950
+ if (typeof candidate.name !== "string" || candidate.name.length === 0 || typeof candidate.source !== "string" || typeof candidate.path !== "string" || typeof candidate.appliedAt !== "number" || candidate.intent !== void 0 && typeof candidate.intent !== "string") {
951
+ return void 0;
952
+ }
953
+ return candidate;
954
+ }
955
+ function asResetRoleRequest(data) {
956
+ if (!data || typeof data !== "object") return void 0;
957
+ const candidate = data;
958
+ if (typeof candidate.name !== "string" || candidate.name.trim().length === 0 || typeof candidate.requestedAt !== "number") {
959
+ return void 0;
960
+ }
961
+ return { name: candidate.name, requestedAt: candidate.requestedAt };
962
+ }
854
963
  async function applyResolved(pi, ctx, state, name, options) {
855
964
  let resolved;
856
965
  try {
@@ -951,7 +1060,10 @@ function subcommandDescription(sub) {
951
1060
  export {
952
1061
  composeSystemPrompt,
953
1062
  index_default as default,
1063
+ findPreviousSessionRoleState,
954
1064
  pickInitialRoleName,
955
1065
  pickNewSessionRoleName,
1066
+ pickPreviousSessionRoleState,
1067
+ resolvePreviousSessionRoleState,
956
1068
  roleCompletions
957
1069
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gkzhb/pi-roles",
3
- "version": "0.2.4-beta.1",
3
+ "version": "0.2.4-beta.2",
4
4
  "description": "Role-based session configuration for pi coding agent. Launch a session as a named role and hot-swap roles mid-session, with optional pi-intercom and pi-mcp-adapter integration.",
5
5
  "keywords": [
6
6
  "pi-package",