@herbertgao/pi-extensions 2026.9.14 → 2026.9.15
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 +3 -3
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
- package/node_modules/pi-jev-auto-mode/README.md +7 -0
- package/node_modules/pi-jev-auto-mode/package.json +1 -1
- package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
- package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
- package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
- package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
- package/node_modules/pi-multi-account/CHANGELOG.md +6 -0
- package/node_modules/pi-multi-account/README.md +15 -1
- package/node_modules/pi-multi-account/index.ts +295 -41
- package/node_modules/pi-multi-account/package.json +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -28,9 +28,9 @@ The package bundles 6 active `@herbertgao/*` child packages—`pi-bark`, `pi-cc-
|
|
|
28
28
|
- `@tifan/pi-rename@0.6.0`
|
|
29
29
|
- `pi-mcp-adapter@2.31.0`
|
|
30
30
|
- `pi-typesafe@0.7.1`
|
|
31
|
-
- `pi-multi-account@1.23.
|
|
31
|
+
- `pi-multi-account@1.23.2`
|
|
32
32
|
- `pi-footer@0.5.1`
|
|
33
|
-
- `pi-jev-auto-mode@0.
|
|
33
|
+
- `pi-jev-auto-mode@0.5.0`
|
|
34
34
|
- `pi-lens@4.2.1`
|
|
35
35
|
- `pi-web-access@0.27.0`
|
|
36
36
|
- `remote-pi@0.7.0`
|
|
@@ -43,7 +43,7 @@ Pi loads their extensions and skills through `node_modules/` paths inside one pa
|
|
|
43
43
|
|
|
44
44
|
`@narumitw/pi-caffeinate@0.49.7` uses the host platform's sleep inhibitor during each Pi agent run. On macOS, `/caffeinate sleep` keeps the system awake while allowing the display to sleep; `/caffeinate display` also keeps the display awake. It releases the inhibitor when the run or session ends.
|
|
45
45
|
|
|
46
|
-
`pi-jev-auto-mode@0.
|
|
46
|
+
`pi-jev-auto-mode@0.5.0` adds fail-closed Jev safety gates for shell commands and file changes. `pi-typesafe@0.7.1` adds the `/typesafe` command and `typesafe_evaluate` tool for explicit structured decisions; both use the TypeSafe API after login.
|
|
47
47
|
|
|
48
48
|
`@herbertgao/resume-from@0.2.0` keeps Claude Code sessions associated with their original repository when the active transcript later moves into a nested cwd. `@herbertgao/sol-pi@0.1.0` adds opt-in Action Fusion, ObservationPack, Evidence-Preserving Reducer, and Online Context Compact; see its [configuration guide](../sol-pi/docs/configuration.md). `pi-lens@4.2.1` expands language routing and bounds retained diagnostic facts across multi-root sessions. `pi-web-access@0.27.0` adds configurable fetch deadlines and answer models plus isolated GitHub clone runtimes. Preferred Thinking 1.0.1 preserves an explicit subagent `--thinking` choice. Deprecated `@tifan/pi-titlebar-spinner` is no longer bundled; Rename remains the single owner of Herdr tab naming.
|
|
49
49
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -137,7 +137,7 @@ THE SOFTWARE.
|
|
|
137
137
|
|
|
138
138
|
## `pi-jev-auto-mode`
|
|
139
139
|
|
|
140
|
-
The aggregate bundles [`pi-jev-auto-mode`](https://github.com/jomatsu/pi-jev-auto-mode) version 0.
|
|
140
|
+
The aggregate bundles [`pi-jev-auto-mode`](https://github.com/jomatsu/pi-jev-auto-mode) version 0.5.0 under its original MIT license:
|
|
141
141
|
|
|
142
142
|
MIT License
|
|
143
143
|
|
|
@@ -163,7 +163,7 @@ THE SOFTWARE.
|
|
|
163
163
|
|
|
164
164
|
## `pi-multi-account`
|
|
165
165
|
|
|
166
|
-
The aggregate bundles the MIT-licensed [`pi-multi-account`](https://github.com/Sarrius/pi-multi-account) package version 1.23.
|
|
166
|
+
The aggregate bundles the MIT-licensed [`pi-multi-account`](https://github.com/Sarrius/pi-multi-account) package version 1.23.2 under its original attribution:
|
|
167
167
|
|
|
168
168
|
MIT License
|
|
169
169
|
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-09-24
|
|
4
|
+
|
|
5
|
+
- **Decision records are compact by default.** An approval is drawn as one line — status, tool,
|
|
6
|
+
latency, and the command — and a block as two, keeping the rationale. Each record used to take
|
|
7
|
+
five padded blocks, so a long session was mostly gate records. `/jev-auto-mode display full`
|
|
8
|
+
(or `"display": "full"`) restores the previous view, and the expanded view shows every detail
|
|
9
|
+
in either mode.
|
|
10
|
+
|
|
3
11
|
## 0.4.1 - 2026-09-18
|
|
4
12
|
|
|
5
13
|
- **An unclear answer passes by default.** `uncertain` defaulted to `deny`, which meant a
|
|
@@ -113,8 +113,14 @@ Packages are discovered in the [package gallery](https://pi.dev/packages) throug
|
|
|
113
113
|
/jev-auto-mode uncertain show what the middle band resolves to
|
|
114
114
|
/jev-auto-mode uncertain deny|ask|allow
|
|
115
115
|
/jev-auto-mode scope all|matched how far the semantic layer reaches
|
|
116
|
+
/jev-auto-mode display full|compact how decision records are drawn
|
|
116
117
|
```
|
|
117
118
|
|
|
119
|
+
Each decision leaves a record in the transcript. `display: "compact"` (the default) folds an
|
|
120
|
+
approval into one line and a block into two (the rationale stays, because a block is something
|
|
121
|
+
to act on). `display: "full"` shows the heading, the engine line, the command, the reasons, and
|
|
122
|
+
the rationale. The expanded view shows every detail in either mode.
|
|
123
|
+
|
|
118
124
|
```
|
|
119
125
|
pi --jev-auto-mode start with auto mode enabled
|
|
120
126
|
```
|
|
@@ -195,6 +201,7 @@ Policy notes: `$PI_CODING_AGENT_DIR/jev-auto-mode-policy.md`.
|
|
|
195
201
|
"maxStateCharacters": 120000,
|
|
196
202
|
"uncertain": "deny",
|
|
197
203
|
"gateScope": "all",
|
|
204
|
+
"display": "compact",
|
|
198
205
|
"thresholds": {}
|
|
199
206
|
}
|
|
200
207
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jev-auto-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Jev (TypeSafe System One) backed auto mode for the Pi coding agent: semantically auto-approves bash, write, and edit tool calls and fails closed when a decision cannot be made.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import {
|
|
56
56
|
DEFAULT_SETTINGS,
|
|
57
57
|
JevAutoModeStore,
|
|
58
|
+
isDisplayMode,
|
|
58
59
|
isGateScope,
|
|
59
60
|
isUncertainAction,
|
|
60
61
|
parseThreshold,
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
import {
|
|
65
66
|
buildConfirmationDialog,
|
|
66
67
|
describeSettings,
|
|
68
|
+
DISPLAY_EXPLANATION,
|
|
67
69
|
GATE_SCOPE_EXPLANATION,
|
|
68
70
|
UNCERTAIN_EXPLANATION,
|
|
69
71
|
formatRuleTable,
|
|
@@ -571,7 +573,7 @@ export function register(pi: ExtensionAPI, options: RegisterOptions = {}): void
|
|
|
571
573
|
default: false,
|
|
572
574
|
});
|
|
573
575
|
|
|
574
|
-
registerDecisionEntryRenderer(pi);
|
|
576
|
+
registerDecisionEntryRenderer(pi, () => state.settings.display);
|
|
575
577
|
|
|
576
578
|
pi.registerCommand(AUTO_MODE_COMMAND, {
|
|
577
579
|
description: "Show or change the Jev auto mode settings",
|
|
@@ -579,7 +581,7 @@ export function register(pi: ExtensionAPI, options: RegisterOptions = {}): void
|
|
|
579
581
|
const value = String(argumentPrefix ?? "");
|
|
580
582
|
const tokens = value.split(/\s+/).filter(Boolean);
|
|
581
583
|
if (tokens.length === 0) {
|
|
582
|
-
return ["status", "on", "off", "policy", "threshold", "scope", "uncertain", "login", "logout"].map((item) => ({
|
|
584
|
+
return ["status", "on", "off", "policy", "threshold", "scope", "uncertain", "display", "login", "logout"].map((item) => ({
|
|
583
585
|
value: item,
|
|
584
586
|
label: item,
|
|
585
587
|
}));
|
|
@@ -756,6 +758,23 @@ export function register(pi: ExtensionAPI, options: RegisterOptions = {}): void
|
|
|
756
758
|
return;
|
|
757
759
|
}
|
|
758
760
|
|
|
761
|
+
if (value.startsWith("display")) {
|
|
762
|
+
const argument = value.slice("display".length).trim();
|
|
763
|
+
if (argument === "") {
|
|
764
|
+
ctx.ui.notify(`display: ${state.settings.display}\n\n${DISPLAY_EXPLANATION}`, "info");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (!isDisplayMode(argument)) {
|
|
768
|
+
ctx.ui.notify(`Expected one of full, compact.\n\n${DISPLAY_EXPLANATION}`, "error");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
state.settings = { ...state.settings, display: argument };
|
|
773
|
+
await save(gateContext);
|
|
774
|
+
ctx.ui.notify(`decision records now display as: ${argument}`, "info");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
759
778
|
if (value.startsWith("uncertain")) {
|
|
760
779
|
const argument = value.slice("uncertain".length).trim();
|
|
761
780
|
if (argument === "") {
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* recorded rationale should not become ammunition for the next tool call.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { Box, Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Box, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { ConditionReport, DecisionSource } from "./decide.ts";
|
|
12
12
|
import { formatThreshold } from "./jev/decide.ts";
|
|
13
|
+
import type { DisplayMode } from "./settings.ts";
|
|
13
14
|
|
|
14
15
|
export const DECISION_ENTRY_TYPE = "jev-auto-mode-decision";
|
|
15
16
|
|
|
@@ -85,12 +86,77 @@ export function formatConditionLines(record: DecisionRecord): string[] {
|
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
/** Collapse whitespace so a multi-line command reads as one line. */
|
|
90
|
+
function oneLine(text: string): string {
|
|
91
|
+
return text.replace(/\s+/g, " ").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CompactDecisionLines {
|
|
95
|
+
/** `🛡 jev allowed · bash · 299ms · <command>`: everything an approval needs. */
|
|
96
|
+
readonly headline: string;
|
|
97
|
+
/**
|
|
98
|
+
* The rationale, only for a call that did not run. An approval does not need its
|
|
99
|
+
* reasoning on screen; a block does, because the user has to act on it.
|
|
100
|
+
*/
|
|
101
|
+
readonly detail?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The compact view: one line for an approval, two for a block.
|
|
106
|
+
*
|
|
107
|
+
* Width is applied at render time. This only decides what the lines say.
|
|
108
|
+
*/
|
|
109
|
+
export function formatCompactLines(record: DecisionRecord): CompactDecisionLines {
|
|
110
|
+
const approved = record.status === "allowed" || record.status === "confirmed";
|
|
111
|
+
const parts = [`${approved ? "🛡" : "⛔"} jev ${STATUS_LABEL[record.status]}`, record.tool];
|
|
112
|
+
if (!approved) parts.push(`via ${record.source}`);
|
|
113
|
+
if (typeof record.latencyMs === "number") parts.push(`${Math.round(record.latencyMs)}ms`);
|
|
114
|
+
const summary = oneLine(record.summary);
|
|
115
|
+
if (summary.length > 0) parts.push(summary);
|
|
116
|
+
const headline = parts.join(" · ");
|
|
117
|
+
if (approved) return { headline };
|
|
118
|
+
return { headline, detail: oneLine(record.rationale) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A single line clipped to the viewport instead of wrapped. */
|
|
122
|
+
class ClippedLine implements Component {
|
|
123
|
+
private readonly text: string;
|
|
124
|
+
|
|
125
|
+
constructor(text: string) {
|
|
126
|
+
this.text = text;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
render(width: number): string[] {
|
|
130
|
+
return [truncateToWidth(this.text, Math.max(1, width))];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(): void {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type DisplayModeSource = () => DisplayMode;
|
|
137
|
+
|
|
138
|
+
export function registerDecisionEntryRenderer(
|
|
139
|
+
pi: Pick<ExtensionAPI, "registerEntryRenderer">,
|
|
140
|
+
displayMode: DisplayModeSource = () => "compact",
|
|
141
|
+
): void {
|
|
89
142
|
pi.registerEntryRenderer<DecisionRecord>(DECISION_ENTRY_TYPE, (entry, options, theme) => {
|
|
90
143
|
const record = entry.data;
|
|
91
144
|
if (!record) return undefined;
|
|
92
145
|
|
|
93
146
|
const approved = record.status === "allowed" || record.status === "confirmed";
|
|
147
|
+
|
|
148
|
+
// The expanded view is the place to read a decision in full, so it ignores the
|
|
149
|
+
// compact setting.
|
|
150
|
+
if (displayMode() === "compact" && !options.expanded) {
|
|
151
|
+
const lines = formatCompactLines(record);
|
|
152
|
+
const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
153
|
+
box.addChild(new ClippedLine(theme.fg(approved ? "muted" : "error", lines.headline)));
|
|
154
|
+
if (lines.detail !== undefined) {
|
|
155
|
+
box.addChild(new ClippedLine(theme.fg("dim", `rationale: ${lines.detail}`)));
|
|
156
|
+
}
|
|
157
|
+
return box;
|
|
158
|
+
}
|
|
159
|
+
|
|
94
160
|
const icon = approved ? "🛡" : "⛔";
|
|
95
161
|
const heading = `${icon} ${theme.bold("jev auto mode")} ${theme.fg(
|
|
96
162
|
approved ? "success" : "error",
|
|
@@ -45,6 +45,23 @@ export interface JevAutoModeSettings {
|
|
|
45
45
|
* visible in `/jev-auto-mode threshold` instead of silently resetting the rule.
|
|
46
46
|
*/
|
|
47
47
|
readonly thresholds: Readonly<Record<string, number>>;
|
|
48
|
+
/**
|
|
49
|
+
* How decision records are drawn in the transcript.
|
|
50
|
+
*
|
|
51
|
+
* `compact` (the default) folds an approval into one line and a block into two, so
|
|
52
|
+
* a long session is not dominated by gate records. `full` shows the heading, the
|
|
53
|
+
* engine line, the command, the reasons, and the rationale. The expanded view (the tool
|
|
54
|
+
* expansion key) always shows every detail, whichever mode is set.
|
|
55
|
+
*/
|
|
56
|
+
readonly display: DisplayMode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type DisplayMode = "full" | "compact";
|
|
60
|
+
|
|
61
|
+
export const DISPLAY_MODES: readonly DisplayMode[] = ["full", "compact"];
|
|
62
|
+
|
|
63
|
+
export function isDisplayMode(value: unknown): value is DisplayMode {
|
|
64
|
+
return typeof value === "string" && DISPLAY_MODES.includes(value as DisplayMode);
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export type SettingsScope = "global" | "project";
|
|
@@ -90,6 +107,7 @@ export const DEFAULT_SETTINGS: JevAutoModeSettings = {
|
|
|
90
107
|
uncertain: "allow",
|
|
91
108
|
gateScope: "all",
|
|
92
109
|
thresholds: {},
|
|
110
|
+
display: "compact",
|
|
93
111
|
};
|
|
94
112
|
|
|
95
113
|
const MAX_PATTERN_ENTRIES = 200;
|
|
@@ -193,6 +211,10 @@ export function parseSettingsPatch(value: unknown): SettingsPatch {
|
|
|
193
211
|
patch.gateScope = record.gateScope;
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
if (record.display !== undefined && isDisplayMode(record.display)) {
|
|
215
|
+
patch.display = record.display;
|
|
216
|
+
}
|
|
217
|
+
|
|
196
218
|
const safeCommands = record.safeCommands === undefined ? undefined : readStringArray(record.safeCommands);
|
|
197
219
|
if (safeCommands !== undefined) patch.safeCommands = safeCommands;
|
|
198
220
|
|
|
@@ -44,6 +44,7 @@ export function describeSettings(settings: JevAutoModeSettings, scope: SettingsS
|
|
|
44
44
|
`max state characters: ${settings.maxStateCharacters}`,
|
|
45
45
|
`uncertain band: ${settings.uncertain}`,
|
|
46
46
|
`gate scope: ${settings.gateScope}`,
|
|
47
|
+
`display: ${settings.display}`,
|
|
47
48
|
].join("\n");
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -149,6 +150,15 @@ export const USAGE_TEXT = [
|
|
|
149
150
|
" /jev-auto-mode uncertain show what the middle band resolves to",
|
|
150
151
|
" /jev-auto-mode uncertain deny|ask|allow",
|
|
151
152
|
" /jev-auto-mode scope all|matched which calls reach Jev",
|
|
153
|
+
" /jev-auto-mode display full|compact how decision records are drawn",
|
|
154
|
+
].join("\n");
|
|
155
|
+
|
|
156
|
+
export const DISPLAY_EXPLANATION = [
|
|
157
|
+
"How decision records are drawn in the transcript.",
|
|
158
|
+
" compact - one line for an approval, two for a block (with the rationale) (default).",
|
|
159
|
+
" full - heading, engine line, command, reasons, and rationale.",
|
|
160
|
+
"The expanded view always shows every detail. New records use the mode at once;",
|
|
161
|
+
"records already on screen switch when Pi redraws them (e.g. toggling expansion).",
|
|
152
162
|
].join("\n");
|
|
153
163
|
|
|
154
164
|
export const GATE_SCOPE_EXPLANATION = [
|
|
@@ -4,6 +4,12 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
## [1.23.2] — 2026-09-24
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Sessions hosted by pi-web's session daemon no longer deactivate after the first one. The in-process root-activation lease (designed for terminal Pi's `/reload` and in-process children) misclassified every later pi-web session as a subagent child, leaving it in `subagent-child-passive` mode with failover, preflight and auto-continue silently disabled. When `PI_WEB_SESSION=1` is present, each session now activates as an independent root; any other multi-session SDK host can opt in with the host-neutral `PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS=1`. Genuine pi-subagents children still run in a runner process marked `PI_SUBAGENT_CHILD=1` and remain passive.
|
|
12
|
+
- In-process independent roots and same-session rehydrates now share a process-scoped canonical child proxy and publication lifetime. A sibling can exit in either order without restoring real OAuth into child-facing `auth.json` or orphaning `models.json` loopback routes; only the final root closes the listener and restores auth. A root whose proxy is disabled still retires public numbered aliases before another root publishes placeholders, and keeps that listener alive through handoff. The surviving root handles newly discovered slots, while explicit `PI_SUBAGENT_CHILD` remains passive.
|
|
7
13
|
|
|
8
14
|
## [1.23.1] — 2026-09-23
|
|
9
15
|
|
|
@@ -179,7 +179,21 @@ Background attempts share the same cooldown and invalidation state as foreground
|
|
|
179
179
|
|
|
180
180
|
### pi-subagents and delegation-broker compatibility
|
|
181
181
|
|
|
182
|
-
`pi-subagents` marks native child processes with `PI_SUBAGENT_CHILD=1` and owns their explicit model plus `fallbackModels` chain.
|
|
182
|
+
`pi-subagents` marks native child processes with `PI_SUBAGENT_CHILD=1` and owns their explicit model plus `fallbackModels` chain. Under terminal Pi, a distinct concurrent in-process child also stays passive while a root activation is live. A replacement with the **same nonempty session ID** reacquires that root's failover ownership without closing its child proxy. The root lease is released at final session shutdown, so `/reload` and `/new` do not permanently lose routing ownership. In passive children this extension stays loaded only for provider/account registration, OAuth request shaping, and catalog support. It deliberately does **not** restore the interactive process's remembered model, persist the child's model as a user preference, switch models, queue work, or auto-continue after errors. The original provider error is returned unchanged so the parent runner can advance its verified fallback chain without a second router competing for model identity.
|
|
183
|
+
|
|
184
|
+
### Multi-session hosts (pi-web and other in-process SDK hosts)
|
|
185
|
+
|
|
186
|
+
Some hosts run many independent sessions in a single long-lived process — pi-web's session daemon, or in-process SDK hosts such as Enso (one session per chat thread). Terminal Pi's in-process root-activation lease would incorrectly demote every session but the first to passive in those hosts.
|
|
187
|
+
|
|
188
|
+
- Under pi-web (`PI_WEB_SESSION=1`, set by the daemon itself) every session activates as an independent root automatically.
|
|
189
|
+
- Any other multi-session host opts in by exporting `PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS=1` in the host process. Set this only when each session truly owns its model; sessions that share one model identity must keep the default single-root behavior.
|
|
190
|
+
- Genuine `pi-subagents` children are unaffected either way: they run in a runner process marked `PI_SUBAGENT_CHILD=1`, which always takes precedence and stays passive.
|
|
191
|
+
|
|
192
|
+
Hosts should not set `PI_WEB_SESSION` themselves — other extensions read it to detect pi-web specifically.
|
|
193
|
+
|
|
194
|
+
In one process, independent roots share one canonical loopback proxy and its child-facing auth/models publication while any root has the child proxy enabled. Closing one root cannot expose its sibling's real OAuth or remove the route its bare children use; the final live root closes the listener and restores the original auth. A rehydrated session with the same ID replaces the old instance's failover ownership rather than counting as a second root. Another process holding the canonical port remains the publisher; a foreign listener is never allowed to overwrite its shared files.
|
|
195
|
+
|
|
196
|
+
Roots with `childProxy: false` still participate in the shared lifetime if another root publishes placeholders. Any public numbered alias they registered earlier is moved to loopback **before** the placeholder appears; they keep the canonical listener alive if the publisher exits first, and only the final root restores auth. With no other publishing root, disabling `childProxy` retains the usual direct route. Hosts should still keep one consistent setting across concurrent roots.
|
|
183
197
|
|
|
184
198
|
[`pi-delegation-broker`](https://github.com/Sarrius/pi-delegation-broker) is an optional companion for splitting work among isolated child agents, with task budgets, reports and verification. Each extension works independently and depends on Agent Pi, not on the other extension. Together, Multi Account manages your interactive account rotation while the broker delegates through Pi's registered providers and models. Neither installs or initializes the other.
|
|
185
199
|
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
statSync,
|
|
43
43
|
writeFileSync,
|
|
44
44
|
} from "node:fs";
|
|
45
|
-
import { createServer, type Server } from "node:http";
|
|
45
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
46
46
|
import { homedir } from "node:os";
|
|
47
47
|
import { Readable } from "node:stream";
|
|
48
48
|
import { dirname, join } from "node:path";
|
|
@@ -1195,7 +1195,7 @@ const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account
|
|
|
1195
1195
|
// Bumped on every release. Printed at startup and in `/multi-account status` so you can verify
|
|
1196
1196
|
// which version Pi actually loaded (a running Pi keeps the version it started with — /login and
|
|
1197
1197
|
// /reload do NOT reload extension code; only a full restart does).
|
|
1198
|
-
const VERSION = "1.23.
|
|
1198
|
+
const VERSION = "1.23.2";
|
|
1199
1199
|
function sourceFingerprint(): string {
|
|
1200
1200
|
try {
|
|
1201
1201
|
const root = dirname(fileURLToPath(import.meta.url));
|
|
@@ -3933,11 +3933,48 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
3933
3933
|
let subagentChild = isSubagentChildProcess();
|
|
3934
3934
|
let hostOwnsSessionModel = false;
|
|
3935
3935
|
let startupModel: { provider: string; id: string } | undefined;
|
|
3936
|
-
//
|
|
3937
|
-
//
|
|
3936
|
+
// Terminal Pi keeps the conservative one-root lease. A replacement of the SAME host
|
|
3937
|
+
// session can reacquire it; an unrelated in-process child remains passive.
|
|
3938
3938
|
const activationKey = Symbol.for("pi-multi-account:active-root-session");
|
|
3939
|
-
const activations = globalThis as typeof globalThis & {
|
|
3939
|
+
const activations = globalThis as typeof globalThis & {
|
|
3940
|
+
[activationKey]?: { owner: object; sessionId?: string; relinquish: () => void };
|
|
3941
|
+
};
|
|
3940
3942
|
const activationOwner = {};
|
|
3943
|
+
// Independent SDK roots share the canonical child listener and its auth/models
|
|
3944
|
+
// publication by port. Their failover state remains private to each session.
|
|
3945
|
+
type SharedProxyMember = {
|
|
3946
|
+
sessionId?: string;
|
|
3947
|
+
ready: boolean;
|
|
3948
|
+
proxyEnabled: boolean;
|
|
3949
|
+
prepareRoutes: (slotIds: readonly string[], port: number) => boolean;
|
|
3950
|
+
relinquish: () => void;
|
|
3951
|
+
handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
3952
|
+
};
|
|
3953
|
+
type SharedProxyCoordinator = {
|
|
3954
|
+
members: Map<object, SharedProxyMember>;
|
|
3955
|
+
// Every published slot stays routable when its discovering root exits first.
|
|
3956
|
+
routes: Map<string, ProxyRoute>;
|
|
3957
|
+
sessionOwners: Map<string, object>;
|
|
3958
|
+
server?: Server;
|
|
3959
|
+
starting?: Promise<number | undefined>;
|
|
3960
|
+
publisher?: object;
|
|
3961
|
+
handlerOwner?: object;
|
|
3962
|
+
};
|
|
3963
|
+
const coordinatorKey = Symbol.for("pi-multi-account:shared-slot-proxy-coordinators");
|
|
3964
|
+
const coordinatorGlobals = globalThis as typeof globalThis & {
|
|
3965
|
+
[coordinatorKey]?: Map<number, SharedProxyCoordinator>;
|
|
3966
|
+
};
|
|
3967
|
+
const coordinators = coordinatorGlobals[coordinatorKey] ??= new Map<number, SharedProxyCoordinator>();
|
|
3968
|
+
let slotProxyCoordinator: SharedProxyCoordinator | undefined;
|
|
3969
|
+
// pi-web's session daemon hosts many independent root sessions in ONE process. The
|
|
3970
|
+
// terminal-Pi lease heuristic would demote every session but the first to passive,
|
|
3971
|
+
// silently disabling failover there. Genuine subagent children under pi-web run in a
|
|
3972
|
+
// runner process marked PI_SUBAGENT_CHILD, which is checked above and still wins.
|
|
3973
|
+
// Any other multi-session SDK host (e.g. Enso) opts in with the host-neutral switch
|
|
3974
|
+
// PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS=1 instead of borrowing pi-web's marker.
|
|
3975
|
+
const multiSessionHost =
|
|
3976
|
+
process.env.PI_WEB_SESSION === "1" ||
|
|
3977
|
+
process.env.PI_MULTI_ACCOUNT_INDEPENDENT_ROOTS === "1";
|
|
3941
3978
|
const explicitCliArgs = parseExplicitCliArgs();
|
|
3942
3979
|
const explicitCli = {
|
|
3943
3980
|
model: explicitCliArgs.model !== undefined,
|
|
@@ -3952,6 +3989,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
3952
3989
|
const sessionInstanceId = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
3953
3990
|
let config = loadConfig();
|
|
3954
3991
|
let sessionClosed = false;
|
|
3992
|
+
let supersededRoot = false;
|
|
3955
3993
|
let manualRouteOwnsErrors = false;
|
|
3956
3994
|
const automaticFailoverEnabled = () => config.enabled && !subagentChild && !sessionClosed;
|
|
3957
3995
|
debugLogEnabled = config.debugLog;
|
|
@@ -10647,15 +10685,90 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10647
10685
|
const SLOT_PROXY_PORT =
|
|
10648
10686
|
Number(process.env.PI_MULTI_ACCOUNT_SLOT_PROXY_PORT) || 41977;
|
|
10649
10687
|
|
|
10650
|
-
/**
|
|
10651
|
-
* One process owns every child-facing file publication. With the normal proxy enabled, the
|
|
10652
|
-
* canonical listening port is the ownership token. A pi-subagents child is never an owner,
|
|
10653
|
-
* even if no parent happens to be listening when it starts.
|
|
10654
|
-
*/
|
|
10688
|
+
/** A shared publisher must still own the canonical socket and a live root membership. */
|
|
10655
10689
|
function ownsSharedChildPublication(): boolean {
|
|
10656
10690
|
if (subagentChild) return false;
|
|
10657
|
-
if (!config.childProxy)
|
|
10658
|
-
|
|
10691
|
+
if (!config.childProxy) {
|
|
10692
|
+
const cohort = slotProxyCoordinator ?? coordinators.get(SLOT_PROXY_PORT);
|
|
10693
|
+
if (cohort?.server || [...(cohort?.members.values() ?? [])].some((member) => member.proxyEnabled)) {
|
|
10694
|
+
return false;
|
|
10695
|
+
}
|
|
10696
|
+
return !Object.entries(readAuthFileRaw()).some(([id, entry]) =>
|
|
10697
|
+
isChildFacingPlaceholderForSlot(entry, id));
|
|
10698
|
+
}
|
|
10699
|
+
if (sessionClosed) return false;
|
|
10700
|
+
const coordinator = slotProxyCoordinator;
|
|
10701
|
+
return !!coordinator && coordinator.members.has(activationOwner) &&
|
|
10702
|
+
coordinator.publisher === activationOwner && coordinator.server?.listening === true &&
|
|
10703
|
+
!slotProxyForeignOwner && slotProxyPort === SLOT_PROXY_PORT;
|
|
10704
|
+
}
|
|
10705
|
+
|
|
10706
|
+
function readyProxyOwner(coordinator: SharedProxyCoordinator): object | undefined {
|
|
10707
|
+
return [...coordinator.members].reverse().find(([, member]) => member.ready)?.[0];
|
|
10708
|
+
}
|
|
10709
|
+
|
|
10710
|
+
function relinquishRoot(): void {
|
|
10711
|
+
if (supersededRoot) return;
|
|
10712
|
+
supersededRoot = true;
|
|
10713
|
+
subagentChild = true;
|
|
10714
|
+
sessionClosed = true;
|
|
10715
|
+
chainEpoch++;
|
|
10716
|
+
completionRouterContext = undefined;
|
|
10717
|
+
if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
|
|
10718
|
+
pendingWakeTimer = undefined;
|
|
10719
|
+
clearUsageStatusTimer();
|
|
10720
|
+
endResumeWatch();
|
|
10721
|
+
clearQueuedInputs();
|
|
10722
|
+
}
|
|
10723
|
+
|
|
10724
|
+
function prepareSharedRoutes(slotIds: readonly string[], port: number): boolean {
|
|
10725
|
+
if (config.childProxy || sessionClosed || subagentChild) return false;
|
|
10726
|
+
slotProxyPort = port;
|
|
10727
|
+
for (const id of slotIds) {
|
|
10728
|
+
if (!registeredSlots.has(id)) continue;
|
|
10729
|
+
const baseUrl = publishedRouteFor(port, id);
|
|
10730
|
+
const family = proxyFamilyOf(id);
|
|
10731
|
+
if (family === "codex") {
|
|
10732
|
+
const cached = codexModelCatalogByProvider.get(id)?.models;
|
|
10733
|
+
registerCodexSlot(pi, id, config.autoDiscoverModels && cached?.length
|
|
10734
|
+
? (cached as Array<Record<string, unknown>>) : undefined, baseUrl);
|
|
10735
|
+
} else if (family === "anthropic") {
|
|
10736
|
+
registerAnthropicSlot(pi, id, DEFAULT_ANTHROPIC_MODELS, baseUrl);
|
|
10737
|
+
}
|
|
10738
|
+
}
|
|
10739
|
+
return true;
|
|
10740
|
+
}
|
|
10741
|
+
|
|
10742
|
+
function joinSharedProxy(sessionId?: string): void {
|
|
10743
|
+
let coordinator = coordinators.get(SLOT_PROXY_PORT);
|
|
10744
|
+
if (!coordinator) {
|
|
10745
|
+
coordinator = { members: new Map(), routes: new Map(), sessionOwners: new Map() };
|
|
10746
|
+
coordinators.set(SLOT_PROXY_PORT, coordinator);
|
|
10747
|
+
}
|
|
10748
|
+
slotProxyCoordinator = coordinator;
|
|
10749
|
+
if (sessionId) {
|
|
10750
|
+
const previousOwner = coordinator.sessionOwners.get(sessionId);
|
|
10751
|
+
if (previousOwner && previousOwner !== activationOwner) {
|
|
10752
|
+
coordinator.members.get(previousOwner)?.relinquish();
|
|
10753
|
+
coordinator.members.delete(previousOwner);
|
|
10754
|
+
if (coordinator.publisher === previousOwner) coordinator.publisher = readyProxyOwner(coordinator);
|
|
10755
|
+
if (coordinator.handlerOwner === previousOwner) coordinator.handlerOwner = readyProxyOwner(coordinator);
|
|
10756
|
+
}
|
|
10757
|
+
coordinator.sessionOwners.set(sessionId, activationOwner);
|
|
10758
|
+
}
|
|
10759
|
+
coordinator.members.set(activationOwner, {
|
|
10760
|
+
sessionId, ready: false, proxyEnabled: config.childProxy,
|
|
10761
|
+
prepareRoutes: prepareSharedRoutes, relinquish: relinquishRoot,
|
|
10762
|
+
handleRequest: handleProxyRequest,
|
|
10763
|
+
});
|
|
10764
|
+
}
|
|
10765
|
+
|
|
10766
|
+
function activateSharedProxy(): void {
|
|
10767
|
+
const coordinator = slotProxyCoordinator;
|
|
10768
|
+
const member = coordinator?.members.get(activationOwner);
|
|
10769
|
+
if (!member || sessionClosed || subagentChild || !coordinator?.server?.listening) return;
|
|
10770
|
+
member.ready = true;
|
|
10771
|
+
coordinator.handlerOwner = activationOwner;
|
|
10659
10772
|
}
|
|
10660
10773
|
|
|
10661
10774
|
refreshDiscovery(true);
|
|
@@ -10683,12 +10796,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10683
10796
|
return proxyFamilyFor(slotId);
|
|
10684
10797
|
}
|
|
10685
10798
|
|
|
10686
|
-
//
|
|
10687
|
-
//
|
|
10688
|
-
// falls back to the public API when this process has no loopback route yet.
|
|
10689
|
-
//
|
|
10799
|
+
// An existing child-facing placeholder (possibly published by another root)
|
|
10800
|
+
// must also stay on its canonical loopback, even when this instance's proxy is off.
|
|
10690
10801
|
function numberedAnthropicBaseUrl(id: string): string {
|
|
10691
10802
|
if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
|
|
10803
|
+
if (isChildFacingPlaceholderForSlot(readAuthFileRaw()[id], id)) return publishedRouteFor(SLOT_PROXY_PORT, id);
|
|
10692
10804
|
return "https://api.anthropic.com";
|
|
10693
10805
|
}
|
|
10694
10806
|
|
|
@@ -10707,12 +10819,15 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10707
10819
|
* slot. If the port is never served the request fails to connect, which is loud and
|
|
10708
10820
|
* harmless — unlike a credential that reaches a provider that cannot read it.
|
|
10709
10821
|
*
|
|
10710
|
-
* With the proxy disabled no
|
|
10711
|
-
*
|
|
10822
|
+
* With the proxy disabled AND no other root's placeholder published, Pi presents a real
|
|
10823
|
+
* credential and the public upstream is correct. A published placeholder always takes
|
|
10824
|
+
* the canonical loopback instead, even if this sibling disabled its own proxy.
|
|
10712
10825
|
*/
|
|
10713
10826
|
function numberedCodexBaseUrl(id: string): string {
|
|
10714
10827
|
if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
|
|
10715
|
-
if (!config.childProxy)
|
|
10828
|
+
if (!config.childProxy && !isChildFacingPlaceholderForSlot(readAuthFileRaw()[id], id)) {
|
|
10829
|
+
return "https://chatgpt.com/backend-api";
|
|
10830
|
+
}
|
|
10716
10831
|
return publishedRouteFor(SLOT_PROXY_PORT, id);
|
|
10717
10832
|
}
|
|
10718
10833
|
|
|
@@ -10776,7 +10891,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10776
10891
|
const verdict = admitRequest({
|
|
10777
10892
|
rawUrl: req.url ?? "",
|
|
10778
10893
|
headers: req.headers ?? {},
|
|
10779
|
-
routes: slotProxyRoutes,
|
|
10894
|
+
routes: slotProxyCoordinator?.server ? slotProxyCoordinator.routes : slotProxyRoutes,
|
|
10780
10895
|
acceptedSecrets: presentedSecret ? [presentedSecret] : [],
|
|
10781
10896
|
});
|
|
10782
10897
|
if (!verdict.ok) {
|
|
@@ -10860,11 +10975,38 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10860
10975
|
/** Start the loopback listener once, preferring a stable port so published routes survive. */
|
|
10861
10976
|
function startSlotProxy(): Promise<number | undefined> {
|
|
10862
10977
|
if (slotProxyPort !== undefined) return Promise.resolve(slotProxyPort);
|
|
10978
|
+
const coordinator = slotProxyCoordinator;
|
|
10979
|
+
if (coordinator?.server?.listening) {
|
|
10980
|
+
slotProxyPort = SLOT_PROXY_PORT;
|
|
10981
|
+
return Promise.resolve(SLOT_PROXY_PORT);
|
|
10982
|
+
}
|
|
10983
|
+
if (coordinator?.starting) {
|
|
10984
|
+
return coordinator.starting.then((port) => {
|
|
10985
|
+
if (port === SLOT_PROXY_PORT && coordinator.server?.listening) {
|
|
10986
|
+
slotProxyPort = SLOT_PROXY_PORT;
|
|
10987
|
+
return SLOT_PROXY_PORT;
|
|
10988
|
+
}
|
|
10989
|
+
return startSlotProxy(); // a foreign owner forced the first member onto a local port
|
|
10990
|
+
});
|
|
10991
|
+
}
|
|
10863
10992
|
if (slotProxyStarting) return slotProxyStarting;
|
|
10864
10993
|
slotProxyStarting = new Promise<number | undefined>((resolve) => {
|
|
10865
10994
|
let retriedOnEphemeralPort = false;
|
|
10995
|
+
let canonicalListener = false;
|
|
10866
10996
|
const server = createServer((req, res) => {
|
|
10867
|
-
|
|
10997
|
+
// The socket outlives its first session. Dispatch only to a live, ready root;
|
|
10998
|
+
// a handoff gap must fail closed, never use a closed root's refresh context.
|
|
10999
|
+
const owner = coordinator?.handlerOwner;
|
|
11000
|
+
const member = owner ? coordinator?.members.get(owner) : undefined;
|
|
11001
|
+
const handler = canonicalListener
|
|
11002
|
+
? (member?.ready ? member.handleRequest : undefined)
|
|
11003
|
+
: handleProxyRequest;
|
|
11004
|
+
if (!handler) {
|
|
11005
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
11006
|
+
res.end(JSON.stringify({ error: { message: "slot proxy handoff in progress" } }));
|
|
11007
|
+
return;
|
|
11008
|
+
}
|
|
11009
|
+
handler(req, res).catch((error) => {
|
|
10868
11010
|
logEvent("slot_proxy_handler_failed", {
|
|
10869
11011
|
reason: error instanceof Error ? error.message : String(error),
|
|
10870
11012
|
});
|
|
@@ -10926,31 +11068,93 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10926
11068
|
// retries on an ephemeral port.
|
|
10927
11069
|
server.once("listening", () => {
|
|
10928
11070
|
const address = server.address();
|
|
10929
|
-
|
|
11071
|
+
const port = typeof address === "object" && address ? address.port : undefined;
|
|
11072
|
+
if (port === SLOT_PROXY_PORT && coordinator) {
|
|
11073
|
+
if (coordinator.members.size === 0) {
|
|
11074
|
+
server.close();
|
|
11075
|
+
resolve(undefined);
|
|
11076
|
+
return;
|
|
11077
|
+
}
|
|
11078
|
+
coordinator.server = server;
|
|
11079
|
+
canonicalListener = true;
|
|
11080
|
+
coordinator.publisher = [...coordinator.members.keys()].at(-1);
|
|
11081
|
+
}
|
|
11082
|
+
// An instance replaced mid-bind must not retain a private listener.
|
|
11083
|
+
if (sessionClosed && port !== SLOT_PROXY_PORT) {
|
|
11084
|
+
server.close();
|
|
11085
|
+
resolve(undefined);
|
|
11086
|
+
return;
|
|
11087
|
+
}
|
|
11088
|
+
slotProxyPort = port;
|
|
10930
11089
|
slotProxyServer = server;
|
|
10931
11090
|
server.unref(); // never hold Pi open on our account
|
|
10932
|
-
logEvent("slot_proxy_listening", { port
|
|
10933
|
-
resolve(
|
|
11091
|
+
logEvent("slot_proxy_listening", { port });
|
|
11092
|
+
resolve(port);
|
|
10934
11093
|
});
|
|
10935
11094
|
server.listen(SLOT_PROXY_PORT, "127.0.0.1");
|
|
10936
11095
|
}).finally(() => {
|
|
11096
|
+
if (coordinator && coordinator.starting === slotProxyStarting) coordinator.starting = undefined;
|
|
11097
|
+
if (coordinator && coordinator.members.size === 0 && coordinators.get(SLOT_PROXY_PORT) === coordinator) {
|
|
11098
|
+
coordinators.delete(SLOT_PROXY_PORT);
|
|
11099
|
+
}
|
|
10937
11100
|
slotProxyStarting = undefined;
|
|
10938
11101
|
});
|
|
11102
|
+
if (coordinator) coordinator.starting = slotProxyStarting;
|
|
10939
11103
|
return slotProxyStarting;
|
|
10940
11104
|
}
|
|
10941
11105
|
|
|
10942
11106
|
function stopSlotProxy() {
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
11107
|
+
const coordinator = slotProxyCoordinator;
|
|
11108
|
+
if (coordinator) {
|
|
11109
|
+
const member = coordinator.members.get(activationOwner);
|
|
11110
|
+
const cleanupWithoutProxy = !config.childProxy && ownsSharedChildPublication();
|
|
11111
|
+
if (member) {
|
|
11112
|
+
coordinator.members.delete(activationOwner);
|
|
11113
|
+
if (member.sessionId && coordinator.sessionOwners.get(member.sessionId) === activationOwner) {
|
|
11114
|
+
coordinator.sessionOwners.delete(member.sessionId);
|
|
11115
|
+
}
|
|
11116
|
+
if (coordinator.publisher === activationOwner) coordinator.publisher = readyProxyOwner(coordinator);
|
|
11117
|
+
if (coordinator.handlerOwner === activationOwner) coordinator.handlerOwner = readyProxyOwner(coordinator);
|
|
11118
|
+
}
|
|
11119
|
+
// An ephemeral listener never owns shared files, even when another process
|
|
11120
|
+
// owns the canonical port. A superseded root cannot close its old canonical
|
|
11121
|
+
// listener: the coordinator still serves it for the replacement.
|
|
11122
|
+
if (slotProxyServer && slotProxyServer !== coordinator.server) {
|
|
11123
|
+
try { slotProxyServer.close(); } catch { /* already closed */ }
|
|
11124
|
+
}
|
|
11125
|
+
if (member && coordinator.members.size === 0) {
|
|
11126
|
+
if (coordinator.server) {
|
|
11127
|
+
// Keep the canonical port claimed until shared files are restored; another
|
|
11128
|
+
// process must not publish a new placeholder during our final cleanup.
|
|
11129
|
+
coordinator.routes.clear();
|
|
11130
|
+
restoreChildFacingAuth();
|
|
11131
|
+
unprovisionOwnLoopbacks();
|
|
11132
|
+
try { coordinator.server.close(); } catch { /* already closed */ }
|
|
11133
|
+
coordinator.server = undefined;
|
|
11134
|
+
}
|
|
11135
|
+
if (!coordinator.server && cleanupWithoutProxy) {
|
|
11136
|
+
unprovisionOwnLoopbacks();
|
|
11137
|
+
restoreChildFacingAuth();
|
|
11138
|
+
}
|
|
11139
|
+
if (!coordinator.starting && coordinators.get(SLOT_PROXY_PORT) === coordinator) {
|
|
11140
|
+
coordinators.delete(SLOT_PROXY_PORT);
|
|
11141
|
+
}
|
|
11142
|
+
coordinator.routes.clear();
|
|
11143
|
+
}
|
|
11144
|
+
slotProxyCoordinator = undefined;
|
|
11145
|
+
slotProxyServer = undefined;
|
|
11146
|
+
slotProxyPort = undefined;
|
|
11147
|
+
slotProxyRoutes.clear();
|
|
11148
|
+
return;
|
|
10949
11149
|
}
|
|
11150
|
+
// Passive children and non-coordinated (proxy-disabled) instances own only
|
|
11151
|
+
// their process-local server. Preserve the pre-existing disabled-proxy cleanup.
|
|
11152
|
+
const publishedSharedFiles = ownsSharedChildPublication();
|
|
11153
|
+
try { slotProxyServer?.close(); } catch { /* already closed */ }
|
|
10950
11154
|
slotProxyServer = undefined;
|
|
10951
11155
|
slotProxyPort = undefined;
|
|
10952
11156
|
slotProxyRoutes.clear();
|
|
10953
|
-
if (!publishedSharedFiles) return;
|
|
11157
|
+
if (!publishedSharedFiles) return;
|
|
10954
11158
|
restoreChildFacingAuth();
|
|
10955
11159
|
unprovisionOwnLoopbacks();
|
|
10956
11160
|
}
|
|
@@ -10962,6 +11166,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10962
11166
|
async function publishProxiedSlots(ctx: any): Promise<void> {
|
|
10963
11167
|
if (!config.childProxy) return;
|
|
10964
11168
|
slotProxyContext = ctx ?? slotProxyContext;
|
|
11169
|
+
const coordinator = slotProxyCoordinator;
|
|
11170
|
+
if (coordinator?.server?.listening && coordinator.members.has(activationOwner) && !sessionClosed) {
|
|
11171
|
+
coordinator.publisher = activationOwner;
|
|
11172
|
+
}
|
|
10965
11173
|
const parent = readAuthFile();
|
|
10966
11174
|
const slots = [
|
|
10967
11175
|
...new Set([
|
|
@@ -10971,12 +11179,20 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10971
11179
|
].filter((id) => proxyFamilyOf(id) !== undefined && isEntryUsable(parent[id]));
|
|
10972
11180
|
if (slots.length === 0) {
|
|
10973
11181
|
if (ownsSharedChildPublication()) {
|
|
10974
|
-
|
|
11182
|
+
// Keep OAuth in the parent-only sidecar while any root is active, even
|
|
11183
|
+
// when every credential is unusable. Only final shutdown restores it.
|
|
11184
|
+
coordinator?.routes.clear();
|
|
10975
11185
|
unprovisionOwnLoopbacks();
|
|
10976
11186
|
}
|
|
11187
|
+
slotProxyRoutes.clear();
|
|
11188
|
+
activateSharedProxy();
|
|
10977
11189
|
return;
|
|
10978
11190
|
}
|
|
10979
11191
|
const port = await startSlotProxy();
|
|
11192
|
+
if (sessionClosed) return;
|
|
11193
|
+
if (port === SLOT_PROXY_PORT && coordinator?.members.has(activationOwner)) {
|
|
11194
|
+
coordinator.publisher = activationOwner;
|
|
11195
|
+
}
|
|
10980
11196
|
if (port === undefined) {
|
|
10981
11197
|
if (ownsSharedChildPublication()) {
|
|
10982
11198
|
restoreChildFacingAuth();
|
|
@@ -10992,8 +11208,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
10992
11208
|
}
|
|
10993
11209
|
if (!ownsSharedChildPublication()) {
|
|
10994
11210
|
logEvent("slot_proxy_local_routes_ready", { port, slots });
|
|
11211
|
+
activateSharedProxy();
|
|
10995
11212
|
return;
|
|
10996
11213
|
}
|
|
11214
|
+
coordinator?.routes.clear();
|
|
11215
|
+
for (const id of slots) {
|
|
11216
|
+
const family = proxyFamilyOf(id);
|
|
11217
|
+
if (family) coordinator?.routes.set(id, { slotId: id, family });
|
|
11218
|
+
}
|
|
11219
|
+
// A proxy-disabled root may have registered a public URL before this root
|
|
11220
|
+
// started. Retire every such route synchronously BEFORE publishing a placeholder.
|
|
11221
|
+
for (const [owner, member] of coordinator?.members ?? []) {
|
|
11222
|
+
if (owner === activationOwner || member.proxyEnabled) continue;
|
|
11223
|
+
if (!member.prepareRoutes(slots, port)) return;
|
|
11224
|
+
member.ready = true;
|
|
11225
|
+
}
|
|
10997
11226
|
shadowChildFacingAuth(slots);
|
|
10998
11227
|
for (const id of slots) {
|
|
10999
11228
|
const family = proxyFamilyOf(id);
|
|
@@ -11029,6 +11258,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11029
11258
|
});
|
|
11030
11259
|
}
|
|
11031
11260
|
}
|
|
11261
|
+
activateSharedProxy();
|
|
11032
11262
|
logEvent("slot_proxy_published", { port, slots });
|
|
11033
11263
|
}
|
|
11034
11264
|
|
|
@@ -11236,13 +11466,23 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11236
11466
|
const startEpoch = ++chainEpoch;
|
|
11237
11467
|
sessionClosed = false;
|
|
11238
11468
|
hostOwnsSessionModel = typeof ctx?.sessionManager?.getBranch === "function";
|
|
11239
|
-
|
|
11240
|
-
|
|
11241
|
-
|
|
11242
|
-
|
|
11243
|
-
|
|
11469
|
+
const rawSessionId = ctx?.sessionManager?.getSessionId?.();
|
|
11470
|
+
const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0
|
|
11471
|
+
? rawSessionId : undefined;
|
|
11472
|
+
if (hostOwnsSessionModel && !subagentChild && !multiSessionHost) {
|
|
11473
|
+
const previous = activations[activationKey];
|
|
11474
|
+
if (previous && previous.owner !== activationOwner) {
|
|
11475
|
+
if (sessionId && previous.sessionId === sessionId && typeof previous.relinquish === "function") {
|
|
11476
|
+
previous.relinquish();
|
|
11477
|
+
} else {
|
|
11478
|
+
subagentChild = true;
|
|
11479
|
+
}
|
|
11244
11480
|
}
|
|
11481
|
+
if (!subagentChild) activations[activationKey] = {
|
|
11482
|
+
owner: activationOwner, sessionId, relinquish: relinquishRoot,
|
|
11483
|
+
};
|
|
11245
11484
|
}
|
|
11485
|
+
if (!subagentChild) joinSharedProxy(sessionId);
|
|
11246
11486
|
// Pi's branch and SDK launch model belong to THIS session. Shared account
|
|
11247
11487
|
// telemetry (or another pane's legacy preference) cannot override them.
|
|
11248
11488
|
startupModel = ctx?.model;
|
|
@@ -11276,11 +11516,19 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11276
11516
|
slotProxyContext = ctx;
|
|
11277
11517
|
// Bind the loopback before discovery so numbered slots register against this process,
|
|
11278
11518
|
// not the real upstream, while a child-facing placeholder is in auth.json.
|
|
11279
|
-
if (config.childProxy)
|
|
11519
|
+
if (config.childProxy) {
|
|
11520
|
+
await startSlotProxy();
|
|
11521
|
+
} else if (slotProxyCoordinator) {
|
|
11522
|
+
const cohort = slotProxyCoordinator;
|
|
11523
|
+
if (cohort.starting) await cohort.starting;
|
|
11524
|
+
if (cohort.server?.listening) prepareSharedRoutes([...cohort.routes.keys()], SLOT_PROXY_PORT);
|
|
11525
|
+
}
|
|
11526
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11280
11527
|
refreshDiscovery(true, ctx);
|
|
11281
11528
|
// Publish the OAuth slots against a route this process serves, so anything spawned
|
|
11282
11529
|
// without this extension can actually run on the account the rotation chose.
|
|
11283
11530
|
await publishProxiedSlots(ctx);
|
|
11531
|
+
if (!config.childProxy) activateSharedProxy();
|
|
11284
11532
|
publishOwnedNativeAliases(ctx);
|
|
11285
11533
|
// Not installed → silent skip (refreshCursorSlots warns only on the explicit path).
|
|
11286
11534
|
if (config.includeCursor) await refreshCursorSlots(readAuthFile(), ctx);
|
|
@@ -11334,22 +11582,28 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11334
11582
|
// runner owns fallback selection in a child, so multiplying that fleet-wide probe across
|
|
11335
11583
|
// every parallel child buys nothing.
|
|
11336
11584
|
if (!subagentChild) await refreshRotationUsage(ctx);
|
|
11585
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11337
11586
|
await syncCodexModelCatalog(ctx);
|
|
11587
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11338
11588
|
await syncOllamaModelCatalog(ctx);
|
|
11589
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11339
11590
|
// Pi restores the session model BEFORE extension catalogs finish registering
|
|
11340
11591
|
// unless the factory returned the Cursor setup promise. Re-apply anyway: a
|
|
11341
11592
|
// cold catalog, a compaction inner session, or a git-reset leftover can still
|
|
11342
11593
|
// leave getModel(cursor, grok-4.6) empty.
|
|
11343
11594
|
if (cursorReady) await cursorReady.catch(() => undefined);
|
|
11595
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11344
11596
|
// Cursor slot catalogs changed too — same stale-hidden-copy repair as the model syncs.
|
|
11345
11597
|
applyOnlyActiveFilter(ctx);
|
|
11346
11598
|
if (!subagentChild) {
|
|
11347
11599
|
await restoreRememberedModel(ctx);
|
|
11600
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11348
11601
|
await ensureReadyModel(
|
|
11349
11602
|
ctx,
|
|
11350
11603
|
"startup preflight: selected account unavailable",
|
|
11351
11604
|
);
|
|
11352
11605
|
}
|
|
11606
|
+
if (sessionClosed || chainEpoch !== startEpoch) return;
|
|
11353
11607
|
startUsageStatusTimer(ctx);
|
|
11354
11608
|
});
|
|
11355
11609
|
|
|
@@ -11358,10 +11612,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11358
11612
|
// Kill every timer and drop the pending continuation so nothing survives the session.
|
|
11359
11613
|
safeOn("session_shutdown", async (_event, ctx) => {
|
|
11360
11614
|
chainEpoch++;
|
|
11361
|
-
if (activations[activationKey] === activationOwner) delete activations[activationKey];
|
|
11615
|
+
if (activations[activationKey]?.owner === activationOwner) delete activations[activationKey];
|
|
11362
11616
|
sessionClosed = true;
|
|
11363
11617
|
completionRouterContext = undefined;
|
|
11364
|
-
rememberUserModel(ctx?.model);
|
|
11618
|
+
if (!supersededRoot) rememberUserModel(ctx?.model);
|
|
11365
11619
|
if (pendingWakeTimer) {
|
|
11366
11620
|
clearTimeout(pendingWakeTimer);
|
|
11367
11621
|
pendingWakeTimer = undefined;
|
|
@@ -11373,7 +11627,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
11373
11627
|
contextGuardCompactionRetryAfter = 0;
|
|
11374
11628
|
settleContextGuardCompaction();
|
|
11375
11629
|
endResumeWatch();
|
|
11376
|
-
//
|
|
11630
|
+
// Release this root's membership; only the final root closes shared child routes.
|
|
11377
11631
|
stopSlotProxy();
|
|
11378
11632
|
watchdogAborting = false;
|
|
11379
11633
|
expectingInjectedContinuation = false;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multi-account",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.2",
|
|
4
4
|
"description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, Kimi For Coding, Cursor, Qwen/Alibaba, and Ollama. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herbertgao/pi-extensions",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.15",
|
|
4
4
|
"description": "Aggregate installer for HerbertGao-maintained Pi extensions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"extensions",
|
|
@@ -88,10 +88,10 @@
|
|
|
88
88
|
"p-limit": "^6.1.0",
|
|
89
89
|
"pi-antigravity": "0.7.2",
|
|
90
90
|
"pi-footer": "0.5.1",
|
|
91
|
-
"pi-jev-auto-mode": "0.
|
|
91
|
+
"pi-jev-auto-mode": "0.5.0",
|
|
92
92
|
"pi-lens": "4.2.1",
|
|
93
93
|
"pi-mcp-adapter": "2.37.0",
|
|
94
|
-
"pi-multi-account": "1.23.
|
|
94
|
+
"pi-multi-account": "1.23.2",
|
|
95
95
|
"pi-typesafe": "0.7.4",
|
|
96
96
|
"pi-web-access": "0.31.0",
|
|
97
97
|
"pidusage": "^4.0.1",
|