@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.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/CHANGELOG.md +91 -0
- package/dist/cli.js +3556 -3504
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +3 -3
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
- package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
- package/dist/types/goals/guided-setup.d.ts +12 -0
- package/dist/types/internal-urls/history-protocol.d.ts +3 -2
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -0
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +10 -0
- package/dist/types/modes/components/dynamic-border.d.ts +5 -3
- package/dist/types/modes/components/login-dialog.d.ts +2 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +0 -2
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/session/messages.d.ts +15 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/tools/grep.d.ts +0 -2
- package/dist/types/tools/read.d.ts +0 -4
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +917 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +270 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/autoresearch/dashboard.ts +2 -2
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/config-cli.ts +15 -3
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/config/settings-schema.ts +10 -0
- package/src/cursor.ts +2 -0
- package/src/discovery/claude-plugins.ts +9 -3
- package/src/discovery/omp-plugins.ts +6 -2
- package/src/discovery/substitute-plugin-root.ts +32 -0
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/prelude-agent.test.ts +20 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/backend.ts +3 -3
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +72 -0
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/prelude.py +28 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/exec/bash-executor.ts +30 -43
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/extensions/wrapper.ts +18 -18
- package/src/extensibility/plugins/bun-git-cache.ts +91 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
- package/src/extensibility/plugins/manager.ts +28 -7
- package/src/goals/guided-setup.ts +29 -1
- package/src/internal-urls/history-protocol.ts +95 -15
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/internal-urls/registry-helpers.ts +50 -1
- package/src/launch/broker.ts +38 -25
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/oauth-discovery.ts +20 -1
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
- package/src/modes/components/agent-dashboard.ts +2 -2
- package/src/modes/components/agent-hub.ts +15 -2
- package/src/modes/components/agent-transcript-viewer.ts +2 -2
- package/src/modes/components/chat-transcript-builder.ts +26 -4
- package/src/modes/components/dynamic-border.ts +9 -6
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/hook-selector.ts +10 -4
- package/src/modes/components/login-dialog.ts +5 -0
- package/src/modes/components/mcp-add-wizard.ts +5 -0
- package/src/modes/components/plan-review-overlay.ts +11 -11
- package/src/modes/components/read-tool-group.ts +1 -8
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/mcp-command-controller.ts +6 -7
- package/src/modes/controllers/selector-controller.ts +24 -5
- package/src/modes/controllers/todo-command-controller.ts +18 -14
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/prompt-action-autocomplete.ts +6 -1
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/types.ts +1 -1
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/grep.md +1 -2
- package/src/prompts/tools/read.md +2 -4
- package/src/sdk.ts +61 -34
- package/src/session/agent-session.ts +283 -39
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/messages.test.ts +66 -0
- package/src/session/messages.ts +37 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/system-prompt.test.ts +36 -0
- package/src/system-prompt.ts +1 -1
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/browser/registry.ts +17 -3
- package/src/tools/eval.ts +14 -9
- package/src/tools/gh.ts +3 -1
- package/src/tools/grep.ts +5 -45
- package/src/tools/path-utils.ts +7 -1
- package/src/tools/read.ts +23 -74
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/utils/title-generator.ts +10 -6
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- package/src/web/search/providers/codex.ts +68 -21
- package/src/web/search/providers/perplexity-auth.ts +20 -11
- package/src/web/search/providers/perplexity.ts +14 -2
|
@@ -75,6 +75,17 @@ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): b
|
|
|
75
75
|
return severity === "concern" || severity === "blocker";
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Append a staleness caveat to an advisor note when newer primary turns arrived
|
|
80
|
+
* after the reviewed transcript window (i.e. `hasFreshBacklog` is true on the
|
|
81
|
+
* advisor runtime at delivery time). Pure function — no session coupling — so it
|
|
82
|
+
* can be unit-tested in isolation and called from `AgentSession#routeAdvice`.
|
|
83
|
+
*/
|
|
84
|
+
export function annotateForStaleness(note: string, hasFreshBacklog: boolean): string {
|
|
85
|
+
if (!hasFreshBacklog) return note;
|
|
86
|
+
return `${note}\n\n_(Note: newer primary turns arrived after this reviewed window — verify this still applies.)_`;
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
/** How an advisor note is routed to the primary. */
|
|
79
90
|
export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
|
|
80
91
|
/** Half-open turn-count fence for the post-interrupt cooldown. */
|
|
@@ -94,6 +105,9 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
|
|
|
94
105
|
* - An interrupting `concern`/`blocker` is normally steered into the agent: into
|
|
95
106
|
* the live turn while one is streaming, or (when idle) a triggered turn so the
|
|
96
107
|
* advice is acted on immediately.
|
|
108
|
+
* - If the primary tail is already a terminal text answer and there is no queued
|
|
109
|
+
* work, late interrupting advice is preserved as a visible card instead of
|
|
110
|
+
* waking the primary to restate completion.
|
|
97
111
|
* - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
|
|
98
112
|
* not auto-resume the stopped run. While the agent is idle — or still tearing
|
|
99
113
|
* the interrupted turn down (`aborting`) — the note is preserved as a visible
|
|
@@ -103,17 +117,19 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
|
|
|
103
117
|
* run instead strands it (it never reaches the running agent) and the withheld
|
|
104
118
|
* notes dump as one burst at the next user prompt — the bug this guards.
|
|
105
119
|
* - During the post-interrupt immune-turn window, further `concern`/`blocker`
|
|
106
|
-
* notes are downgraded to asides;
|
|
120
|
+
* notes are downgraded to asides; preservation still wins.
|
|
107
121
|
*/
|
|
108
122
|
export function resolveAdvisorDeliveryChannel(opts: {
|
|
109
123
|
severity: AdvisorSeverity | undefined;
|
|
110
124
|
autoResumeSuppressed: boolean;
|
|
111
125
|
streaming: boolean;
|
|
112
126
|
aborting: boolean;
|
|
127
|
+
terminalAnswerNoQueuedWork?: boolean;
|
|
113
128
|
interruptImmuneTurnActive?: boolean;
|
|
114
129
|
}): AdvisorDeliveryChannel {
|
|
115
130
|
if (!isInterruptingSeverity(opts.severity)) return "aside";
|
|
116
131
|
if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
|
|
132
|
+
if (opts.terminalAnswerNoQueuedWork && !opts.streaming && !opts.aborting) return "preserve";
|
|
117
133
|
if (opts.interruptImmuneTurnActive) return "aside";
|
|
118
134
|
return "steer";
|
|
119
135
|
}
|
package/src/advisor/runtime.ts
CHANGED
|
@@ -63,9 +63,126 @@ export interface AdvisorRuntimeHost {
|
|
|
63
63
|
notifyFailure?(error: unknown): void;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const ADVISOR_QUARANTINE_PREFIX = "Advisor response quarantined";
|
|
67
|
+
|
|
68
|
+
/** Signals that an advisor response was discarded before it could become model-visible context. */
|
|
69
|
+
export class AdvisorOutputQuarantinedError extends Error {
|
|
70
|
+
constructor(message: string) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "AdvisorOutputQuarantinedError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface AdvisorOutputHazard {
|
|
77
|
+
label: string;
|
|
78
|
+
pattern: RegExp;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ADVISOR_OUTPUT_ONLY_HAZARDS: readonly AdvisorOutputHazard[] = [
|
|
82
|
+
{ label: "account-deletion claim", pattern: /\buser\b.{0,80}\b(?:deleted|erased)\b.{0,80}\baccount\b/i },
|
|
83
|
+
{
|
|
84
|
+
label: "instruction override",
|
|
85
|
+
pattern: /\bignore\s+(?:all\s+)?(?:prior|previous|earlier)\s+(?:user\s+)?instructions\b/i,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
label: "destructive shell command",
|
|
89
|
+
pattern: /\brm\s+(?=(?:-[a-z]+\s*)*-[a-z]*r[a-z]*)(?=(?:-[a-z]+\s*)*-[a-z]*f[a-z]*)(?:-[a-z]+\s*)+/i,
|
|
90
|
+
},
|
|
91
|
+
{ label: "denial instruction", pattern: /\bdeny\s+(?:this|it|the\s+request)\s+if\s+(?:asked|questioned)\b/i },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replaces an advisor assistant turn that requested unavailable tools or generated
|
|
96
|
+
* output-only destructive directives with a sanitized error before dispatch.
|
|
97
|
+
*
|
|
98
|
+
* The agent loop records assistant turns before dispatching tools. Without this
|
|
99
|
+
* pre-dispatch rewrite, an advisor hallucination can leave unrelated text in the
|
|
100
|
+
* advisor transcript even though the action itself never executes.
|
|
101
|
+
*/
|
|
102
|
+
export function quarantineAdvisorUnsafeOutput(
|
|
103
|
+
message: AssistantMessage,
|
|
104
|
+
availableToolNames: ReadonlySet<string>,
|
|
105
|
+
sourceText = "",
|
|
106
|
+
): string | undefined {
|
|
107
|
+
const reasons: string[] = [];
|
|
108
|
+
const unavailableToolNames = new Set<string>();
|
|
109
|
+
const generatedParts: string[] = [];
|
|
110
|
+
for (const block of message.content) {
|
|
111
|
+
if (block.type === "toolCall" && !availableToolNames.has(block.name)) unavailableToolNames.add(block.name);
|
|
112
|
+
if (block.type === "toolCall" && block.name === "advise" && typeof block.arguments.note === "string") {
|
|
113
|
+
generatedParts.push(block.arguments.note);
|
|
114
|
+
}
|
|
115
|
+
if (block.type === "text") generatedParts.push(block.text);
|
|
116
|
+
}
|
|
117
|
+
if (unavailableToolNames.size > 0) {
|
|
118
|
+
const names = [...unavailableToolNames].sort();
|
|
119
|
+
const toolLabel = names.length === 1 ? "tool" : "tools";
|
|
120
|
+
reasons.push(`requested unavailable ${toolLabel} ${names.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const generatedText = generatedParts.join("\n");
|
|
124
|
+
if (generatedText) {
|
|
125
|
+
const labels: string[] = [];
|
|
126
|
+
const matchedLabels: string[] = [];
|
|
127
|
+
for (const hazard of ADVISOR_OUTPUT_ONLY_HAZARDS) {
|
|
128
|
+
if (!hazard.pattern.test(generatedText)) continue;
|
|
129
|
+
matchedLabels.push(hazard.label);
|
|
130
|
+
if (!hazard.pattern.test(sourceText)) labels.push(hazard.label);
|
|
131
|
+
}
|
|
132
|
+
// A transcript can quote a destructive command while the advisor turns it
|
|
133
|
+
// into a new instruction. The output-only override remains sufficient
|
|
134
|
+
// provenance to quarantine that combination.
|
|
135
|
+
if (
|
|
136
|
+
matchedLabels.includes("destructive shell command") &&
|
|
137
|
+
labels.includes("instruction override") &&
|
|
138
|
+
!labels.includes("destructive shell command")
|
|
139
|
+
) {
|
|
140
|
+
labels.push("destructive shell command");
|
|
141
|
+
}
|
|
142
|
+
if (labels.includes("destructive shell command") || labels.length >= 3) {
|
|
143
|
+
reasons.push(`generated output-only destructive directives: ${labels.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (reasons.length === 0) return undefined;
|
|
148
|
+
|
|
149
|
+
const messageText = `${ADVISOR_QUARANTINE_PREFIX}: ${reasons.join("; ")}`;
|
|
150
|
+
message.content = [{ type: "text", text: messageText }];
|
|
151
|
+
message.stopReason = "error";
|
|
152
|
+
message.stopDetails = undefined;
|
|
153
|
+
message.toolCallAbortMessages = undefined;
|
|
154
|
+
message.providerPayload = undefined;
|
|
155
|
+
message.errorMessage = messageText;
|
|
156
|
+
return messageText;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Builds the provenance text used to decide whether hazardous advisor output was
|
|
161
|
+
* generated by the advisor or came from model-visible primary/tool context.
|
|
162
|
+
*/
|
|
163
|
+
export function buildAdvisorQuarantineSourceText(currentInput: string, messages: readonly AgentMessage[]): string {
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
if (currentInput) parts.push(currentInput);
|
|
166
|
+
for (const message of messages) {
|
|
167
|
+
if (message.role !== "toolResult") continue;
|
|
168
|
+
for (const block of message.content) {
|
|
169
|
+
if (block.type === "text") parts.push(block.text);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return parts.join("\n");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Maximum number of late-arrival coalescing rounds in {@link AdvisorRuntime.#collectAndMaintainBatch}.
|
|
176
|
+
* After this many rounds any items still in `#pending` are left for the next drain iteration
|
|
177
|
+
* so a pathologically fast primary + slow `maintainContext` cannot stall dispatch indefinitely.
|
|
178
|
+
*/
|
|
179
|
+
const MAX_COALESCE_ROUNDS = 3;
|
|
180
|
+
|
|
66
181
|
interface PendingDelta {
|
|
67
182
|
text: string;
|
|
68
183
|
turns: number;
|
|
184
|
+
/** Whether the primary was mid-turn (willContinue:true) when this delta was rendered. */
|
|
185
|
+
wip: boolean;
|
|
69
186
|
}
|
|
70
187
|
|
|
71
188
|
interface CatchupWaiter {
|
|
@@ -107,13 +224,36 @@ export class AdvisorRuntime {
|
|
|
107
224
|
return this.#backlog;
|
|
108
225
|
}
|
|
109
226
|
|
|
110
|
-
|
|
227
|
+
/**
|
|
228
|
+
* True when `#pending` is non-empty while the drain loop is busy — i.e., newer
|
|
229
|
+
* primary turns arrived after the current batch's transcript window was fixed
|
|
230
|
+
* but before the advisor model finished processing it. The delivery path uses
|
|
231
|
+
* this to annotate advice that was generated without seeing those newer turns.
|
|
232
|
+
* Can be true during `agent.prompt()`, a `maintainContext` await, or a retry
|
|
233
|
+
* sleep — any time `#drain` is busy and a concurrent `onTurnEnd` pushed.
|
|
234
|
+
*/
|
|
235
|
+
get hasFreshBacklog(): boolean {
|
|
236
|
+
return this.#pending.length > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Called after each primary turn ends. Renders the incremental delta and
|
|
241
|
+
* queues it for the advisor model.
|
|
242
|
+
*
|
|
243
|
+
* @param messages - Live primary transcript snapshot (defaults to `snapshotMessages()`).
|
|
244
|
+
* @param opts.willContinue - When `true` the primary is mid-turn (more tool-call
|
|
245
|
+
* steps will follow). The rendered heading is tagged `[in progress]` so the
|
|
246
|
+
* advisor knows to withhold critique on partial work. The flag is carried on
|
|
247
|
+
* the delta and forwarded to the reprime path so it is never silently dropped.
|
|
248
|
+
*/
|
|
249
|
+
onTurnEnd(messages?: AgentMessage[], opts?: { willContinue?: boolean }): void {
|
|
111
250
|
if (this.disposed) return;
|
|
112
251
|
const all = messages ?? this.host.snapshotMessages();
|
|
113
252
|
this.#latestMessages = all;
|
|
114
|
-
const
|
|
253
|
+
const wip = opts?.willContinue ?? false;
|
|
254
|
+
const render = this.#renderDelta(all, wip);
|
|
115
255
|
if (render) {
|
|
116
|
-
this.#pending.push({ text: render, turns: 1 });
|
|
256
|
+
this.#pending.push({ text: render, turns: 1, wip });
|
|
117
257
|
this.#backlog++;
|
|
118
258
|
this.#notifyWaiters();
|
|
119
259
|
void this.#drain();
|
|
@@ -200,7 +340,7 @@ export class AdvisorRuntime {
|
|
|
200
340
|
this.#wakeAllWaiters();
|
|
201
341
|
}
|
|
202
342
|
|
|
203
|
-
#renderDelta(messages?: AgentMessage[]): string | null {
|
|
343
|
+
#renderDelta(messages?: AgentMessage[], wip = false): string | null {
|
|
204
344
|
const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
|
|
205
345
|
if (all.length < this.#lastCount) {
|
|
206
346
|
this.#lastCount = all.length;
|
|
@@ -209,7 +349,7 @@ export class AdvisorRuntime {
|
|
|
209
349
|
}
|
|
210
350
|
const delta = all
|
|
211
351
|
.slice(this.#lastCount)
|
|
212
|
-
.filter(m => !(m.role === "custom" &&
|
|
352
|
+
.filter(m => !(m.role === "custom" && m.customType === "advisor"))
|
|
213
353
|
.map(m => this.#dedupContextMessage(m));
|
|
214
354
|
this.#lastCount = all.length;
|
|
215
355
|
if (delta.length === 0) return null;
|
|
@@ -223,7 +363,8 @@ export class AdvisorRuntime {
|
|
|
223
363
|
expandEditDiffs: true,
|
|
224
364
|
});
|
|
225
365
|
if (!md.trim()) return null;
|
|
226
|
-
|
|
366
|
+
const heading = wip ? "### Session update [in progress — more steps follow]" : "### Session update";
|
|
367
|
+
return `${heading}\n\n${md}`;
|
|
227
368
|
}
|
|
228
369
|
|
|
229
370
|
/**
|
|
@@ -236,14 +377,13 @@ export class AdvisorRuntime {
|
|
|
236
377
|
*/
|
|
237
378
|
#dedupContextMessage(msg: AgentMessage): AgentMessage {
|
|
238
379
|
if (msg.role !== "custom") return msg;
|
|
239
|
-
|
|
240
|
-
if (!
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
return { ...(msg as object), content: "(unchanged — still in effect)" } as AgentMessage;
|
|
380
|
+
// Narrowed to CustomMessage: customType and content are properly typed.
|
|
381
|
+
if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(msg.customType)) return msg;
|
|
382
|
+
if (typeof msg.content !== "string") return msg;
|
|
383
|
+
if (this.#seenContext.get(msg.customType) === msg.content) {
|
|
384
|
+
return { ...msg, content: "(unchanged — still in effect)" };
|
|
245
385
|
}
|
|
246
|
-
this.#seenContext.set(
|
|
386
|
+
this.#seenContext.set(msg.customType, msg.content);
|
|
247
387
|
return msg;
|
|
248
388
|
}
|
|
249
389
|
|
|
@@ -284,46 +424,90 @@ export class AdvisorRuntime {
|
|
|
284
424
|
}
|
|
285
425
|
}
|
|
286
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Collect all currently pending deltas into one batch, running
|
|
429
|
+
* `maintainContext` for correct token budgeting. Loops until the pending
|
|
430
|
+
* queue is stable (no new deltas arrived during a maintenance check) or a
|
|
431
|
+
* reprime is triggered. Every `await` inside the loop has an epoch guard so
|
|
432
|
+
* a reset/dispose mid-await cannot leak a stale batch into the post-reset
|
|
433
|
+
* conversation.
|
|
434
|
+
*
|
|
435
|
+
* The coalescing loop is capped at {@link MAX_COALESCE_ROUNDS} iterations so
|
|
436
|
+
* a pathologically fast primary combined with a slow `maintainContext` cannot
|
|
437
|
+
* stall dispatch indefinitely — any items still in `#pending` after the cap
|
|
438
|
+
* are left for the next drain iteration.
|
|
439
|
+
*
|
|
440
|
+
* Returns `null` when the epoch was invalidated — caller should `continue`.
|
|
441
|
+
* Returns `{ batch: null, finalTurns }` when there is nothing to render but
|
|
442
|
+
* backlog still needs to be decremented.
|
|
443
|
+
*/
|
|
444
|
+
async #collectAndMaintainBatch(
|
|
445
|
+
epoch: number,
|
|
446
|
+
): Promise<{ batch: string | null; finalTurns: number; wip: boolean } | null> {
|
|
447
|
+
const initial = this.#pending.splice(0);
|
|
448
|
+
let batchText = initial.map(b => b.text).join("\n\n");
|
|
449
|
+
let turns = initial.reduce((sum, b) => sum + b.turns, 0);
|
|
450
|
+
// Track WIP state of the most recent delta — forwarded to the reprime
|
|
451
|
+
// #renderDelta so a willContinue:true turn keeps its [in progress] heading
|
|
452
|
+
// even when the full transcript is replayed from scratch. Also returned to
|
|
453
|
+
// #drain so the retry-requeue path preserves it on failed turns.
|
|
454
|
+
let wip = initial.at(-1)?.wip ?? false;
|
|
455
|
+
|
|
456
|
+
for (let round = 0; round < MAX_COALESCE_ROUNDS; round++) {
|
|
457
|
+
if (this.host.maintainContext) {
|
|
458
|
+
const incomingTokens = estimateTokens({ role: "user", content: batchText, timestamp: Date.now() });
|
|
459
|
+
let shouldReprime = false;
|
|
460
|
+
try {
|
|
461
|
+
shouldReprime = await this.host.maintainContext(incomingTokens);
|
|
462
|
+
} catch (err) {
|
|
463
|
+
logger.debug("advisor context maintenance failed", { err: String(err) });
|
|
464
|
+
}
|
|
465
|
+
// Epoch guard — a reset/dispose during the maintainContext await
|
|
466
|
+
// invalidates this batch.
|
|
467
|
+
if (this.#epoch !== epoch) return null;
|
|
468
|
+
|
|
469
|
+
if (shouldReprime) {
|
|
470
|
+
// Tally deltas that arrived during this await before #resetAdvisorContext
|
|
471
|
+
// wipes #pending, so finalTurns stays accurate for backlog accounting.
|
|
472
|
+
// Also capture the latest WIP state before the queue is cleared.
|
|
473
|
+
const lateItems = this.#pending.splice(0);
|
|
474
|
+
turns += lateItems.reduce((sum, b) => sum + b.turns, 0);
|
|
475
|
+
if (lateItems.length > 0) wip = lateItems.at(-1)!.wip;
|
|
476
|
+
this.#resetAdvisorContext(false, false);
|
|
477
|
+
return { batch: this.#renderDelta(this.#latestMessages, wip), finalTurns: turns, wip };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// On the final round stop here — any late arrivals would ship without
|
|
482
|
+
// a subsequent maintainContext budget check. Leave them in #pending for
|
|
483
|
+
// the next drain iteration where they will be properly budgeted.
|
|
484
|
+
if (round === MAX_COALESCE_ROUNDS - 1) break;
|
|
485
|
+
|
|
486
|
+
// Coalesce any deltas that arrived while we were awaiting maintenance.
|
|
487
|
+
// If none arrived the batch is stable and we're done; otherwise merge,
|
|
488
|
+
// update WIP state, and re-check the maintenance budget.
|
|
489
|
+
const late = this.#pending.splice(0);
|
|
490
|
+
if (late.length === 0) break;
|
|
491
|
+
batchText = [batchText, ...late.map(b => b.text)].join("\n\n");
|
|
492
|
+
turns += late.reduce((sum, b) => sum + b.turns, 0);
|
|
493
|
+
wip = late.at(-1)!.wip;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { batch: batchText || null, finalTurns: turns, wip };
|
|
497
|
+
}
|
|
498
|
+
|
|
287
499
|
async #drain(): Promise<void> {
|
|
288
500
|
if (this.#busy) return;
|
|
289
501
|
this.#busy = true;
|
|
290
502
|
try {
|
|
291
503
|
while (!this.disposed && this.#pending.length) {
|
|
292
|
-
const popped = this.#pending.splice(0);
|
|
293
504
|
const epoch = this.#epoch;
|
|
294
|
-
|
|
295
|
-
// join with a blank line rather than a `---` rule.
|
|
296
|
-
const candidateBatch = popped.map(b => b.text).join("\n\n");
|
|
297
|
-
const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
|
|
298
|
-
const incomingTokens = estimateTokens({
|
|
299
|
-
role: "user",
|
|
300
|
-
content: candidateBatch,
|
|
301
|
-
timestamp: Date.now(),
|
|
302
|
-
});
|
|
505
|
+
const result = await this.#collectAndMaintainBatch(epoch);
|
|
303
506
|
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
try {
|
|
307
|
-
shouldReprime = await this.host.maintainContext(incomingTokens);
|
|
308
|
-
} catch (err) {
|
|
309
|
-
logger.debug("advisor context maintenance failed", { err: String(err) });
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
// A reset/dispose during context maintenance invalidates this batch.
|
|
313
|
-
if (this.#epoch !== epoch) continue;
|
|
507
|
+
// Epoch was invalidated during batch collection; restart the loop.
|
|
508
|
+
if (result === null) continue;
|
|
314
509
|
|
|
315
|
-
|
|
316
|
-
let finalTurns: number;
|
|
317
|
-
if (shouldReprime) {
|
|
318
|
-
// Promotion could not fit the advisor's context — re-prime.
|
|
319
|
-
const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
|
|
320
|
-
this.#resetAdvisorContext(false, false);
|
|
321
|
-
batch = this.#renderDelta(this.#latestMessages);
|
|
322
|
-
finalTurns = turnsCovered + newTurns;
|
|
323
|
-
} else {
|
|
324
|
-
batch = candidateBatch;
|
|
325
|
-
finalTurns = turnsCovered;
|
|
326
|
-
}
|
|
510
|
+
const { batch, finalTurns, wip } = result;
|
|
327
511
|
|
|
328
512
|
if (this.disposed || batch === null) {
|
|
329
513
|
this.#backlog = Math.max(0, this.#backlog - finalTurns);
|
|
@@ -333,33 +517,34 @@ export class AdvisorRuntime {
|
|
|
333
517
|
|
|
334
518
|
let success = false;
|
|
335
519
|
// Capture the advisor's message count BEFORE the prompt so a failure can
|
|
336
|
-
// roll back the user batch + synthetic assistant-error turn
|
|
337
|
-
// appends to internal state. Without this, a retry would replay the
|
|
338
|
-
//
|
|
339
|
-
//
|
|
520
|
+
// roll back the user batch + synthetic assistant-error turn Agent.#runLoop
|
|
521
|
+
// appends to internal state. Without this, a retry would replay the failed
|
|
522
|
+
// batch on top of stale turns and the dropped-after-3 path would leak
|
|
523
|
+
// orphan failures into the next successful run's context.
|
|
340
524
|
const messageSnapshot = this.agent.state.messages.length;
|
|
341
525
|
try {
|
|
342
526
|
// Reset the host's per-update advisor state (one-advise-per-update
|
|
343
|
-
// gate) before each model cycle
|
|
344
|
-
// fresh budget. Dedupe history persists across cycles.
|
|
527
|
+
// gate) before each model cycle so the new batch starts fresh.
|
|
345
528
|
this.host.beginAdvisorUpdate?.();
|
|
346
529
|
await this.agent.prompt(batch);
|
|
347
|
-
//
|
|
348
|
-
// resolves
|
|
349
|
-
//
|
|
350
|
-
// Treat that as a failed turn so OpenRouter ZDR-style endpoint
|
|
351
|
-
// rejections trip the retry/notify path instead of looking like a
|
|
352
|
-
// successful empty cycle.
|
|
530
|
+
// Agent.#runLoop catches provider/stream failures internally and
|
|
531
|
+
// resolves prompt() cleanly with stopReason: "error". Treat that
|
|
532
|
+
// as a failed turn so endpoint rejections trip the retry path.
|
|
353
533
|
const promptError = this.agent.state.error;
|
|
354
534
|
if (promptError) throw new Error(promptError);
|
|
535
|
+
// A content-less stop is a deliberate silent review — the documented
|
|
536
|
+
// verifier behavior ("prefer silence when the agent is on track") — and
|
|
537
|
+
// completes the turn. Sessions can legitimately have nothing to advise
|
|
538
|
+
// on for any number of consecutive turns, so silence is never warned
|
|
539
|
+
// about (#5216 did, spamming "Advisor unavailable" at quiet models).
|
|
540
|
+
const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
|
|
541
|
+
if (turnError) throw turnError;
|
|
355
542
|
success = true;
|
|
356
543
|
this.#consecutiveFailures = 0;
|
|
357
544
|
this.#failureNotified = false;
|
|
358
545
|
} catch (err) {
|
|
359
|
-
// reset()/dispose() aborts the in-flight prompt;
|
|
360
|
-
// reset
|
|
361
|
-
// (reset already cleared #pending and rewound the cursor) instead of
|
|
362
|
-
// requeuing it into the post-reset conversation.
|
|
546
|
+
// reset()/dispose() aborts the in-flight prompt; treat it as a
|
|
547
|
+
// reset, not a transient failure — drop the stale batch.
|
|
363
548
|
if (this.#epoch !== epoch) continue;
|
|
364
549
|
this.#rollbackFailedTurn(messageSnapshot);
|
|
365
550
|
logger.debug("advisor turn failed", { err: String(err) });
|
|
@@ -368,8 +553,15 @@ export class AdvisorRuntime {
|
|
|
368
553
|
} catch (hookErr) {
|
|
369
554
|
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
|
|
370
555
|
}
|
|
371
|
-
|
|
372
|
-
|
|
556
|
+
if (err instanceof AdvisorOutputQuarantinedError) {
|
|
557
|
+
const rePrime = this.#pending.length > 0 ? this.#latestMessages : undefined;
|
|
558
|
+
// Wake catchup waiters only when nothing is re-primed; otherwise the
|
|
559
|
+
// re-primed turn restores the backlog and waiters resolve on its completion.
|
|
560
|
+
this.#resetAdvisorContext(true, !rePrime);
|
|
561
|
+
if (rePrime) this.onTurnEnd(rePrime);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
// Epoch guard after the async error hook.
|
|
373
565
|
if (this.#epoch !== epoch) continue;
|
|
374
566
|
this.#consecutiveFailures++;
|
|
375
567
|
if (this.#consecutiveFailures >= 3) {
|
|
@@ -383,13 +575,13 @@ export class AdvisorRuntime {
|
|
|
383
575
|
}
|
|
384
576
|
}
|
|
385
577
|
this.#consecutiveFailures = 0;
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
578
|
+
// Drop the seen-context so the next turn re-expands primary-context
|
|
579
|
+
// prompts instead of marking them "unchanged" against content the
|
|
580
|
+
// advisor never received.
|
|
389
581
|
this.#seenContext.clear();
|
|
390
582
|
success = true;
|
|
391
583
|
} else {
|
|
392
|
-
this.#pending.unshift({ text: batch, turns: finalTurns });
|
|
584
|
+
this.#pending.unshift({ text: batch, turns: finalTurns, wip });
|
|
393
585
|
await Bun.sleep(this.retryDelayMs);
|
|
394
586
|
}
|
|
395
587
|
}
|
|
@@ -405,6 +597,17 @@ export class AdvisorRuntime {
|
|
|
405
597
|
}
|
|
406
598
|
}
|
|
407
599
|
|
|
600
|
+
/**
|
|
601
|
+
* The only malformed advisor turn shape: the prompt resolved but produced no
|
|
602
|
+
* assistant response at all. Everything an assistant message carries — advice,
|
|
603
|
+
* reasoning, or deliberate silence (empty `stop`) — is a completed review.
|
|
604
|
+
*/
|
|
605
|
+
function getAdvisorTurnError(messages: readonly AgentMessage[]): Error | undefined {
|
|
606
|
+
if (messages.length === 0) return undefined;
|
|
607
|
+
if (messages.some(message => message.role === "assistant")) return undefined;
|
|
608
|
+
return new Error("Advisor turn ended without an assistant response");
|
|
609
|
+
}
|
|
610
|
+
|
|
408
611
|
type TextualContent = string | readonly (TextContent | ImageContent)[];
|
|
409
612
|
|
|
410
613
|
function obfuscateTextualContent(obfuscator: SecretObfuscator, content: TextualContent): TextualContent {
|
|
@@ -76,11 +76,11 @@ export class AutoLearnController {
|
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
if (event.type === "agent_end") {
|
|
79
|
-
this.#onAgentEnd();
|
|
79
|
+
this.#onAgentEnd(event);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
#onAgentEnd(): void {
|
|
83
|
+
#onAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
|
|
84
84
|
// Snapshot and reset every turn: the counter describes only the
|
|
85
85
|
// just-finished turn, so below-threshold, disabled, and plan-mode stops
|
|
86
86
|
// must not let tool calls accumulate into a later turn.
|
|
@@ -95,6 +95,18 @@ export class AutoLearnController {
|
|
|
95
95
|
this.#suppressNext = false;
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
98
|
+
// Never nudge a turn that ended in an abort (ESC, cancel, etc.). The
|
|
99
|
+
// abort flag on the session is unreliable by the time agent_end is
|
|
100
|
+
// deferred to subscribers; read stopReason from the event messages.
|
|
101
|
+
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
102
|
+
const message = event.messages[i];
|
|
103
|
+
if (message && typeof message === "object" && "role" in message && message.role === "assistant") {
|
|
104
|
+
if ("stopReason" in message && message.stopReason === "aborted") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
98
110
|
// Honor a live opt-out: the subscription outlives the setting, so re-check
|
|
99
111
|
// the current flag rather than trusting install-time state.
|
|
100
112
|
if (!this.#settings.get("autolearn.enabled")) return;
|
|
@@ -132,7 +144,7 @@ export class AutoLearnController {
|
|
|
132
144
|
display: false,
|
|
133
145
|
attribution: "user",
|
|
134
146
|
},
|
|
135
|
-
{ deliverAs: "nextTurn", triggerTurn: true },
|
|
147
|
+
{ deliverAs: "nextTurn", triggerTurn: true, acceptTerminalEmptyStop: true },
|
|
136
148
|
)
|
|
137
149
|
.then(started => {
|
|
138
150
|
if (!started) this.#suppressNext = false;
|
|
@@ -95,9 +95,9 @@ export function createDashboardController(): DashboardController {
|
|
|
95
95
|
done(undefined);
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
98
|
-
if (matchesKey(data, "up") || data
|
|
98
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
99
99
|
scrollOffset = Math.max(0, scrollOffset - 1);
|
|
100
|
-
} else if (matchesKey(data, "down") || data
|
|
100
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
101
101
|
scrollOffset = Math.min(maxScroll, scrollOffset + 1);
|
|
102
102
|
} else if (matchesKey(data, "pageUp")) {
|
|
103
103
|
scrollOffset = Math.max(0, scrollOffset - viewportRows);
|
package/src/cli/args.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
STRING_SETTERS,
|
|
14
14
|
STRING_VALUE_FLAGS,
|
|
15
15
|
} from "./flag-tables";
|
|
16
|
+
import { CliUsageError } from "./usage-error";
|
|
16
17
|
|
|
17
18
|
export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
|
|
18
19
|
|
|
@@ -310,6 +311,17 @@ export function reportUnrecognizedFlags(
|
|
|
310
311
|
return true;
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
/** Emit a clean CLI usage error without an internal stack trace. */
|
|
315
|
+
export function reportCliUsageError(
|
|
316
|
+
error: unknown,
|
|
317
|
+
write: (text: string) => void = text => process.stderr.write(text),
|
|
318
|
+
): boolean {
|
|
319
|
+
if (!(error instanceof CliUsageError)) return false;
|
|
320
|
+
write(`${chalk.red(`Error: ${error.message}`)}\n`);
|
|
321
|
+
write(`Run \`${APP_NAME} --help\` for available flags.\n`);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
export function getExtraHelpText(): string {
|
|
314
326
|
return `${chalk.bold("Environment Variables:")}
|
|
315
327
|
${chalk.dim("# Core Providers")}
|
|
@@ -660,19 +660,25 @@ interface MigrateSkip {
|
|
|
660
660
|
|
|
661
661
|
function credentialIdentity(provider: string, credential: AuthCredential): string {
|
|
662
662
|
if (credential.type === "api_key") return "(api key)";
|
|
663
|
-
|
|
663
|
+
const base = credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`;
|
|
664
|
+
return credential.orgId ? `${base} (${credential.orgName ?? credential.orgId})` : base;
|
|
664
665
|
}
|
|
665
666
|
|
|
666
667
|
/**
|
|
667
668
|
* Build the set of "identities already on the broker" so re-runs are idempotent.
|
|
668
|
-
* For OAuth, identity = email|accountId|projectId
|
|
669
|
-
*
|
|
670
|
-
*
|
|
669
|
+
* For OAuth, identity = email|accountId|projectId, each org-qualified when the
|
|
670
|
+
* row carries an organization (one Anthropic email can hold a Team seat AND a
|
|
671
|
+
* personal Max plan — those must migrate as two rows). A row with NO base
|
|
672
|
+
* identity but an orgId (login recovered neither email nor account) is marked
|
|
673
|
+
* by the org alone, so re-running migrate does not re-upload a stale refresh
|
|
674
|
+
* token over the broker's newer one. For api_key, we collapse to a single
|
|
675
|
+
* marker per provider (broker has no concept of "multiple api keys per
|
|
676
|
+
* provider with different identities"; upsert would coalesce them).
|
|
671
677
|
*/
|
|
672
678
|
function indexBrokerSnapshot(snapshot: {
|
|
673
679
|
credentials: Array<{
|
|
674
680
|
provider: string;
|
|
675
|
-
credential: { type: string; email?: string; accountId?: string; projectId?: string };
|
|
681
|
+
credential: { type: string; email?: string; accountId?: string; projectId?: string; orgId?: string };
|
|
676
682
|
}>;
|
|
677
683
|
}): Map<string, Set<string>> {
|
|
678
684
|
const out = new Map<string, Set<string>>();
|
|
@@ -681,9 +687,18 @@ function indexBrokerSnapshot(snapshot: {
|
|
|
681
687
|
if (entry.credential.type === "api_key") {
|
|
682
688
|
ids.add("@api_key");
|
|
683
689
|
} else {
|
|
684
|
-
|
|
685
|
-
if (entry.credential.
|
|
686
|
-
if (entry.credential.
|
|
690
|
+
const orgSuffix = entry.credential.orgId ? `|org:${entry.credential.orgId}` : "";
|
|
691
|
+
if (entry.credential.email) ids.add(`email:${entry.credential.email}${orgSuffix}`);
|
|
692
|
+
if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}${orgSuffix}`);
|
|
693
|
+
if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}${orgSuffix}`);
|
|
694
|
+
if (
|
|
695
|
+
!entry.credential.email &&
|
|
696
|
+
!entry.credential.accountId &&
|
|
697
|
+
!entry.credential.projectId &&
|
|
698
|
+
entry.credential.orgId
|
|
699
|
+
) {
|
|
700
|
+
ids.add(`org:${entry.credential.orgId}`);
|
|
701
|
+
}
|
|
687
702
|
}
|
|
688
703
|
out.set(entry.provider, ids);
|
|
689
704
|
}
|
|
@@ -694,9 +709,13 @@ function brokerAlreadyHas(existing: Map<string, Set<string>>, provider: string,
|
|
|
694
709
|
const ids = existing.get(provider);
|
|
695
710
|
if (!ids) return false;
|
|
696
711
|
if (credential.type === "api_key") return ids.has("@api_key");
|
|
697
|
-
|
|
698
|
-
if (credential.
|
|
699
|
-
if (credential.
|
|
712
|
+
const orgSuffix = credential.orgId ? `|org:${credential.orgId}` : "";
|
|
713
|
+
if (credential.email && ids.has(`email:${credential.email}${orgSuffix}`)) return true;
|
|
714
|
+
if (credential.accountId && ids.has(`accountId:${credential.accountId}${orgSuffix}`)) return true;
|
|
715
|
+
if (credential.projectId && ids.has(`projectId:${credential.projectId}${orgSuffix}`)) return true;
|
|
716
|
+
if (!credential.email && !credential.accountId && !credential.projectId && credential.orgId) {
|
|
717
|
+
return ids.has(`org:${credential.orgId}`);
|
|
718
|
+
}
|
|
700
719
|
return false;
|
|
701
720
|
}
|
|
702
721
|
|
|
@@ -573,8 +573,12 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
573
573
|
: row.ok === false
|
|
574
574
|
? chalk.red("FAIL ")
|
|
575
575
|
: chalk.yellow("unknown ");
|
|
576
|
-
const
|
|
576
|
+
const base =
|
|
577
577
|
row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
|
|
578
|
+
// Two subscriptions (orgs) can share one email — without the org a
|
|
579
|
+
// failed row can't say which subscription needs re-login.
|
|
580
|
+
const org = row.orgName ?? row.orgId;
|
|
581
|
+
const identity = org && org !== base ? `${base} (${org})` : base;
|
|
578
582
|
const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
|
|
579
583
|
const reasonParts: string[] = [];
|
|
580
584
|
if (row.reason) reasonParts.push(row.reason);
|