dsh-codex-community 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/CONTRIBUTING.en.md +54 -0
  3. package/CONTRIBUTING.md +54 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +12 -0
  6. package/README.en.md +98 -0
  7. package/README.md +98 -0
  8. package/SECURITY.md +41 -0
  9. package/SUPPORT.md +17 -0
  10. package/THIRD_PARTY_NOTICES.md +25 -0
  11. package/codex-community.patch.yml +13 -0
  12. package/dist/client/index.js +1037 -0
  13. package/dist/host/index.mjs +98 -0
  14. package/dist/internal/authorization-bridge.mjs +662 -0
  15. package/dist/internal/authorization-commit-tracker.mjs +49 -0
  16. package/dist/internal/codex-authorization.mjs +202 -0
  17. package/dist/internal/codex-credential-store.mjs +164 -0
  18. package/dist/internal/codex-identifiers.mjs +4 -0
  19. package/dist/internal/codex-pi-provider.mjs +137 -0
  20. package/dist/internal/codex-provider-runtime.mjs +256 -0
  21. package/dist/internal/codex-route-adapter.mjs +133 -0
  22. package/dist/internal/codex-session-resources.mjs +64 -0
  23. package/dist/internal/failure-normalizer.mjs +456 -0
  24. package/dist/internal/image-policy.mjs +45 -0
  25. package/dist/internal/quota-observer.mjs +142 -0
  26. package/dist/internal/reliability.mjs +12 -0
  27. package/dist/internal/remote-image-input.mjs +801 -0
  28. package/dist/internal/session-preference-command.mjs +52 -0
  29. package/dist/internal/session-preferences.mjs +93 -0
  30. package/dist/internal/stream-resilience.mjs +273 -0
  31. package/docs/README.en.md +15 -0
  32. package/docs/README.md +15 -0
  33. package/docs/architecture.en.md +106 -0
  34. package/docs/architecture.md +106 -0
  35. package/docs/compatibility.en.md +53 -0
  36. package/docs/compatibility.md +53 -0
  37. package/docs/configuration.en.md +61 -0
  38. package/docs/configuration.md +61 -0
  39. package/docs/contribution-sources.en.md +35 -0
  40. package/docs/contribution-sources.md +35 -0
  41. package/docs/github-about.md +15 -0
  42. package/docs/releases/v0.0.1.acceptance.json +160 -0
  43. package/docs/releases/v0.0.1.md +174 -0
  44. package/docs/releasing.en.md +290 -0
  45. package/docs/releasing.md +290 -0
  46. package/docs/testing.en.md +85 -0
  47. package/docs/testing.md +85 -0
  48. package/docs/troubleshooting.en.md +47 -0
  49. package/docs/troubleshooting.md +47 -0
  50. package/package.json +144 -0
  51. package/types/client.d.ts +160 -0
  52. package/types/index.d.ts +22 -0
  53. package/types/reliability.d.ts +78 -0
@@ -0,0 +1,52 @@
1
+ const USAGE = "用法:/codex [status|reset|set fast on|off|set transport auto|sse|websocket|websocket-cached] / Usage: /codex [status|reset|set fast on|off|set transport auto|sse|websocket|websocket-cached]"
2
+ const TRANSPORTS = new Set(["auto", "sse", "websocket", "websocket-cached"])
3
+
4
+ /** Register the TUI/Web command surface for process-local session preferences. */
5
+ export function registerCodexSessionCommand(ctx, preferences, options = {}) {
6
+ const resetSession = options.resetSession ?? (() => undefined)
7
+ if (typeof resetSession !== "function") throw new TypeError("resetSession must be a function")
8
+ return ctx.commands.register({
9
+ name: "codex",
10
+ description: "管理当前会话的 Codex 请求偏好 / Manage Codex request preferences for this session",
11
+ input: { hint: "[status|reset|set fast on|off|set transport auto|sse|websocket|websocket-cached]" },
12
+ recordInput: false,
13
+ handler: async ({ rawInput, agent }) => {
14
+ const parts = String(rawInput).trim().split(/\s+/u).filter(Boolean)
15
+ const action = parts.length === 0 ? ["status"] : parts
16
+ try {
17
+ if (action.length === 1 && action[0] === "status") {
18
+ return success(preferences.resolve(String(agent.id)))
19
+ }
20
+ if (action.length === 1 && action[0] === "reset") {
21
+ const sessionId = String(agent.id)
22
+ preferences.remove(sessionId)
23
+ resetSession(sessionId)
24
+ return success(preferences.resolve(sessionId), "已重置当前会话。 / Session preferences reset. ")
25
+ }
26
+ if (action.length === 3 && action[0] === "set" && action[1] === "fast") {
27
+ if (action[2] !== "on" && action[2] !== "off") return { kind: "error", text: USAGE }
28
+ return success(preferences.configure(String(agent.id), { fast: action[2] === "on" }))
29
+ }
30
+ if (action.length === 3 && action[0] === "set" && action[1] === "transport") {
31
+ if (!TRANSPORTS.has(action[2])) return { kind: "error", text: USAGE }
32
+ return success(preferences.configure(String(agent.id), { transport: action[2] }))
33
+ }
34
+ return { kind: "error", text: USAGE }
35
+ } catch {
36
+ return {
37
+ kind: "error",
38
+ text: "Codex 会话偏好暂时不可用。 / Codex session preferences are temporarily unavailable.",
39
+ }
40
+ }
41
+ },
42
+ })
43
+ }
44
+
45
+ function success(snapshot, prefix = "") {
46
+ const fast = snapshot.fast ? "on" : "off"
47
+ const fastZh = snapshot.fast ? "开启" : "关闭"
48
+ return {
49
+ kind: "success",
50
+ text: `${prefix}Fast: ${fast} · Transport: ${snapshot.transport} / Fast:${fastZh} · 传输:${snapshot.transport}`,
51
+ }
52
+ }
@@ -0,0 +1,93 @@
1
+ const MAX_SESSION_ID_CHARS = 256
2
+ const DEFAULT_MAX_SESSIONS = 512
3
+ const MAX_MAX_SESSIONS = 4_096
4
+ const TRANSPORTS = new Set(["auto", "sse", "websocket", "websocket-cached"])
5
+ const PREFERENCE_KEYS = new Set(["fast", "transport"])
6
+
7
+ const SAFE_DEFAULTS = Object.freeze({ fast: false, transport: "auto" })
8
+
9
+ /**
10
+ * Process-local, per-session request preferences.
11
+ *
12
+ * Callers see one immutable snapshot and never learn how entries are bounded or
13
+ * merged. The provider may resolve with no session id for direct/diagnostic
14
+ * calls; those always use safe defaults. Lifecycle owners remove an entry when
15
+ * its agent is disposed.
16
+ */
17
+ export function createSessionPreferences(options = {}) {
18
+ const input = plainObject(options, "options")
19
+ assertOnlyKeys(input, ["defaultFast", "defaultTransport", "maxSessions"], "option")
20
+ const maxSessions = input.maxSessions ?? DEFAULT_MAX_SESSIONS
21
+ if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > MAX_MAX_SESSIONS) {
22
+ throw new TypeError(`maxSessions must be an integer from 1 to ${MAX_MAX_SESSIONS}`)
23
+ }
24
+ const defaults = snapshot({
25
+ fast: input.defaultFast ?? SAFE_DEFAULTS.fast,
26
+ transport: input.defaultTransport ?? SAFE_DEFAULTS.transport,
27
+ })
28
+ const entries = new Map()
29
+ let disposed = false
30
+
31
+ return Object.freeze({
32
+ resolve(sessionId) {
33
+ if (disposed || sessionId === undefined) return defaults
34
+ return entries.get(validSessionId(sessionId)) ?? defaults
35
+ },
36
+
37
+ configure(sessionId, patch) {
38
+ if (disposed) throw new Error("session preferences are disposed")
39
+ const id = validSessionId(sessionId)
40
+ const change = plainObject(patch, "preference patch")
41
+ const keys = Object.keys(change)
42
+ if (keys.length === 0 || keys.some((key) => !PREFERENCE_KEYS.has(key))) {
43
+ throw new TypeError("preference patch must set only fast and/or transport")
44
+ }
45
+ if (!entries.has(id) && entries.size >= maxSessions) {
46
+ throw new Error("session preference capacity reached")
47
+ }
48
+ const next = snapshot({ ...(entries.get(id) ?? defaults), ...change })
49
+ entries.set(id, next)
50
+ return next
51
+ },
52
+
53
+ remove(sessionId) {
54
+ if (disposed) return false
55
+ return entries.delete(validSessionId(sessionId))
56
+ },
57
+
58
+ dispose() {
59
+ if (disposed) return
60
+ disposed = true
61
+ entries.clear()
62
+ },
63
+ })
64
+ }
65
+
66
+ function snapshot(value) {
67
+ if (typeof value.fast !== "boolean") throw new TypeError("fast must be a boolean")
68
+ if (!TRANSPORTS.has(value.transport)) {
69
+ throw new TypeError("transport must be auto, sse, websocket, or websocket-cached")
70
+ }
71
+ return Object.freeze({ fast: value.fast, transport: value.transport })
72
+ }
73
+
74
+ function validSessionId(value) {
75
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_SESSION_ID_CHARS) {
76
+ throw new TypeError(`session id must contain 1 to ${MAX_SESSION_ID_CHARS} characters`)
77
+ }
78
+ return value
79
+ }
80
+
81
+ function plainObject(value, label) {
82
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
83
+ throw new TypeError(`${label} must be an object`)
84
+ }
85
+ return value
86
+ }
87
+
88
+ function assertOnlyKeys(value, allowed, label) {
89
+ const accepted = new Set(allowed)
90
+ if (Object.keys(value).some((key) => !accepted.has(key))) {
91
+ throw new TypeError(`unknown ${label}`)
92
+ }
93
+ }
@@ -0,0 +1,273 @@
1
+ import { normalizeCodexFailure } from "./failure-normalizer.mjs"
2
+ import { CODEX_ROUTE_ID } from "./codex-identifiers.mjs"
3
+
4
+ const PARTIAL_RECOVERABLE_CODES = new Set([
5
+ "QUOTA",
6
+ "RATE_LIMIT",
7
+ "SERVER",
8
+ "TIMEOUT",
9
+ "TRANSPORT",
10
+ "EMPTY_RESPONSE",
11
+ "QUOTA_OR_RATE_LIMIT",
12
+ ])
13
+
14
+ const SUCCESSFUL_FINISH_KINDS = new Set(["stop", "tool-calls", "max-tokens"])
15
+
16
+ const DIRECTLY_HANDLED_FAILURE_KINDS = new Set([
17
+ "account-quota",
18
+ "ambiguous-limit",
19
+ "transport",
20
+ ])
21
+
22
+ function createTracker() {
23
+ return {
24
+ blocks: new Map(),
25
+ indexes: new Set(),
26
+ hasVisibleText: false,
27
+ hasToolCall: false,
28
+ }
29
+ }
30
+
31
+ function rememberChunk(tracker, chunk) {
32
+ if ("index" in chunk && Number.isSafeInteger(chunk.index)) tracker.indexes.add(chunk.index)
33
+ switch (chunk.type) {
34
+ case "block-start":
35
+ tracker.blocks.set(chunk.index, {
36
+ type: chunk.blockType,
37
+ text: "",
38
+ open: true,
39
+ })
40
+ if (chunk.blockType === "tool-call") tracker.hasToolCall = true
41
+ break
42
+ case "text-delta": {
43
+ const block = tracker.blocks.get(chunk.index)
44
+ if (block?.type === "text") block.text += chunk.text
45
+ if (chunk.text.length > 0) tracker.hasVisibleText = true
46
+ break
47
+ }
48
+ case "reasoning-delta": {
49
+ const block = tracker.blocks.get(chunk.index)
50
+ if (block?.type === "reasoning") block.text += chunk.text
51
+ break
52
+ }
53
+ case "tool-call-delta":
54
+ tracker.hasToolCall = true
55
+ break
56
+ case "block-end": {
57
+ const block = tracker.blocks.get(chunk.index)
58
+ if (block !== undefined) {
59
+ block.open = false
60
+ if (chunk.block.type === "text") {
61
+ block.text = chunk.block.text
62
+ if (chunk.block.text.length > 0) tracker.hasVisibleText = true
63
+ }
64
+ }
65
+ if (chunk.block.type === "tool-call") tracker.hasToolCall = true
66
+ break
67
+ }
68
+ }
69
+ }
70
+
71
+ function safeToRecover(tracker) {
72
+ if (!tracker.hasVisibleText || tracker.hasToolCall) return false
73
+ for (const block of tracker.blocks.values()) {
74
+ if (!block.open) continue
75
+ if (block.type !== "text" && block.type !== "reasoning") return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ function nextIndex(tracker) {
81
+ if (tracker.indexes.size === 0) return 0
82
+ const index = Math.max(...tracker.indexes) + 1
83
+ if (!Number.isSafeInteger(index)) throw new RangeError("no safe block index remains for recovery notice")
84
+ return index
85
+ }
86
+
87
+ function closeOpenTextBlocks(tracker) {
88
+ const chunks = []
89
+ for (const [index, block] of tracker.blocks) {
90
+ if (!block.open) continue
91
+ chunks.push({
92
+ type: "block-end",
93
+ index,
94
+ block: { type: block.type, text: block.text },
95
+ })
96
+ block.open = false
97
+ }
98
+ return chunks
99
+ }
100
+
101
+ function recoveryNotice(normalized) {
102
+ if (normalized.failure.code === "QUOTA") {
103
+ return `⚠️ ${normalized.failure.message} 已保存上方未完成回复。 / The partial response above was preserved.`
104
+ }
105
+ return "⚠️ 回复流在完成前中断。为避免重复输出或工具副作用,本插件没有重放整次请求,并已保存上方内容;请发送“继续”。 / The response stream was interrupted. The full request was not replayed; send “continue” to resume."
106
+ }
107
+
108
+ function recoveredChunks(tracker, normalized) {
109
+ const chunks = closeOpenTextBlocks(tracker)
110
+ const index = nextIndex(tracker)
111
+ const text = recoveryNotice(normalized)
112
+ chunks.push(
113
+ { type: "block-start", index, blockType: "text" },
114
+ { type: "text-delta", index, text },
115
+ { type: "block-end", index, block: { type: "text", text } },
116
+ )
117
+ return chunks
118
+ }
119
+
120
+ function partialFailure(failure) {
121
+ return Object.freeze({
122
+ message: "Codex 在产生部分输出后失败;已禁用整次请求重放,以避免重复输出或工具副作用。 / Codex failed after partial output; full-request replay was disabled to prevent duplicate output or tool side effects.",
123
+ code: "PARTIAL_RESPONSE",
124
+ })
125
+ }
126
+
127
+ function prematureEndFailure() {
128
+ return Object.freeze({
129
+ message: "Codex stream ended before a terminal finish chunk",
130
+ code: "TRANSPORT",
131
+ })
132
+ }
133
+
134
+ function* finishWithFailure({
135
+ tracker,
136
+ chunk,
137
+ pendingUsage,
138
+ options,
139
+ config,
140
+ partialResponseRecovery,
141
+ }) {
142
+ const normalized = normalizeCodexFailure(chunk.reason.failure)
143
+ const failure = normalized.failure
144
+ if (failure.code === "QUOTA") {
145
+ notify(config.onQuota, {
146
+ provider: options.provider,
147
+ model: options.model,
148
+ ...(normalized.facts.reset?.epochMs === undefined
149
+ ? {}
150
+ : { resetAt: normalized.facts.reset.epochMs }),
151
+ })
152
+ }
153
+ const canRecover = partialResponseRecovery
154
+ && PARTIAL_RECOVERABLE_CODES.has(failure.code)
155
+ && safeToRecover(tracker)
156
+
157
+ if (canRecover) {
158
+ yield* recoveredChunks(tracker, normalized)
159
+ if (pendingUsage !== undefined) yield pendingUsage
160
+ notify(config.onRecovery, {
161
+ provider: options.provider,
162
+ model: options.model,
163
+ code: failure.code,
164
+ requestId: failure.requestId,
165
+ })
166
+ yield { type: "finish", reason: { kind: "stop" } }
167
+ return
168
+ }
169
+
170
+ if (pendingUsage !== undefined) yield pendingUsage
171
+ const finalFailure = failure.code !== "QUOTA"
172
+ && (tracker.hasVisibleText || tracker.hasToolCall)
173
+ && PARTIAL_RECOVERABLE_CODES.has(failure.code)
174
+ ? partialFailure(failure)
175
+ : failure
176
+ yield {
177
+ ...chunk,
178
+ reason: { kind: "error", failure: finalFailure },
179
+ }
180
+ }
181
+
182
+ function notify(callback, detail) {
183
+ if (typeof callback !== "function") return
184
+ try {
185
+ callback(Object.freeze(detail))
186
+ } catch {
187
+ // Observation hooks must never turn a completed provider stream into a failure.
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Stabilize one Codex stream without replaying a request that already emitted
193
+ * content. Safe text is closed and persisted; tool-bearing streams fail closed.
194
+ */
195
+ export async function* stabilizeCodexStream(options, next, config = {}) {
196
+ if (options?.provider !== CODEX_ROUTE_ID) {
197
+ yield* next()
198
+ return
199
+ }
200
+
201
+ const tracker = createTracker()
202
+ const partialResponseRecovery = config.partialResponseRecovery !== false
203
+ let pendingUsage
204
+
205
+ try {
206
+ for await (const chunk of next()) {
207
+ if (chunk.type === "usage") {
208
+ if (pendingUsage !== undefined) yield pendingUsage
209
+ pendingUsage = chunk
210
+ continue
211
+ }
212
+
213
+ if (chunk.type !== "finish") {
214
+ if (pendingUsage !== undefined) {
215
+ yield pendingUsage
216
+ pendingUsage = undefined
217
+ }
218
+ rememberChunk(tracker, chunk)
219
+ yield chunk
220
+ continue
221
+ }
222
+
223
+ if (chunk.reason.kind !== "error") {
224
+ if (pendingUsage !== undefined) yield pendingUsage
225
+ if (SUCCESSFUL_FINISH_KINDS.has(chunk.reason.kind)) {
226
+ notify(config.onSuccess, {
227
+ provider: options.provider,
228
+ model: options.model,
229
+ })
230
+ }
231
+ yield chunk
232
+ return
233
+ }
234
+
235
+ yield* finishWithFailure({
236
+ tracker,
237
+ chunk,
238
+ pendingUsage,
239
+ options,
240
+ config,
241
+ partialResponseRecovery,
242
+ })
243
+ return
244
+ }
245
+ } catch (failure) {
246
+ const normalized = normalizeCodexFailure(failure)
247
+ if (!DIRECTLY_HANDLED_FAILURE_KINDS.has(normalized.facts.kind)) throw failure
248
+ yield* finishWithFailure({
249
+ tracker,
250
+ chunk: {
251
+ type: "finish",
252
+ reason: { kind: "error", failure },
253
+ },
254
+ pendingUsage,
255
+ options,
256
+ config,
257
+ partialResponseRecovery,
258
+ })
259
+ return
260
+ }
261
+
262
+ yield* finishWithFailure({
263
+ tracker,
264
+ chunk: {
265
+ type: "finish",
266
+ reason: { kind: "error", failure: prematureEndFailure() },
267
+ },
268
+ pendingUsage,
269
+ options,
270
+ config,
271
+ partialResponseRecovery,
272
+ })
273
+ }
@@ -0,0 +1,15 @@
1
+ # Documentation index
2
+
3
+ [简体中文](README.md) | [English](README.en.md)
4
+
5
+ - [Architecture and module boundaries](architecture.en.md)
6
+ - [Configuration reference](configuration.en.md)
7
+ - [Compatibility and acceptance status](compatibility.en.md)
8
+ - [Troubleshooting](troubleshooting.en.md)
9
+ - [Contribution and licensing rules](contribution-sources.en.md)
10
+ - [Testing strategy](testing.en.md)
11
+ - [Release process](releasing.en.md)
12
+ - [GitHub About and topics](github-about.md)
13
+ - [v0.0.1 Release copy](releases/v0.0.1.md)
14
+
15
+ Every “verified” claim must point to repository tests, CI, or release-artifact evidence. “Wired” and “planned” must not be presented as “supported.”
package/docs/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # 文档索引
2
+
3
+ [简体中文](README.md) | [English](README.en.md)
4
+
5
+ - [架构与模块边界](architecture.md)
6
+ - [配置参考](configuration.md)
7
+ - [兼容性与验收状态](compatibility.md)
8
+ - [故障排查](troubleshooting.md)
9
+ - [贡献与许可规范](contribution-sources.md)
10
+ - [测试策略](testing.md)
11
+ - [发布流程](releasing.md)
12
+ - [GitHub About 与 Topics](github-about.md)
13
+ - [v0.0.1 Release 文案](releases/v0.0.1.md)
14
+
15
+ 文档中的“已验证”必须能指向仓库内测试、CI 或发布产物证据;“已接线”和“计划中”不能写成“已支持”。
@@ -0,0 +1,106 @@
1
+ # Architecture
2
+
3
+ [简体中文](architecture.md) | [English](architecture.en.md)
4
+
5
+ ## Responsibilities
6
+
7
+ This plugin registers the Codex route, settings namespace, OAuth flow, session preferences, and reliability policy. Message conversion, model protocols, tool permissions, session persistence, and OAuth protocol behavior use public interfaces from DSH, `@deepseek-ai/dsh-llm-pi-ai`, and pi-ai.
8
+
9
+ ```text
10
+ DSH Web settings ── loopback RPC ── AuthorizationBridge
11
+
12
+ dsh-codex/openai-codex
13
+
14
+ CodexCredentialStore
15
+
16
+ ChatGPT OAuth grant
17
+
18
+ Harness Agent Loop
19
+
20
+ ├── SessionPreferences ── Fast / transport
21
+
22
+
23
+ StreamResilience ── CodexRouteAdapter (`dsh-codex`)
24
+
25
+
26
+ PiAiAdapter (`openai-codex`) ── CodexPiProvider
27
+
28
+ └── attachment service ── ImagePolicy
29
+ ```
30
+
31
+ ## ProviderRuntime
32
+
33
+ `ProviderRuntime` creates the `dsh-codex` route and OAuth flow under the `dsh-codex` settings namespace. The outer `CodexRouteAdapter` maps the DSH route to PiAiAdapter's internal canonical `openai-codex` provider, then restores the external route at provider-info, model, stream-history-source, and error boundaries. This preserves pi-ai semantics for response IDs, reasoning signatures, tool-call IDs, and replay envelopes.
34
+
35
+ Each operation obtains an immutable profile from current settings. A concurrent settings update cannot alter an operation already in progress; the next operation receives the new configuration while the outer adapter instance remains stable.
36
+
37
+ The model catalog comes from the installed pi-ai version. Model selection, image budgets, cache policy, timeout, and bounded retry are resolved once at the profile boundary. Unknown or duplicate model selections fail before network I/O; an absent `models` value advertises the complete catalog, while an explicit empty array advertises none.
38
+
39
+ ## CodexCredentialStore
40
+
41
+ The credential record key is `dsh-codex/openai-codex`. The adapter reads and modifies only that record and accepts OAuth grants only. API keys, records belonging to another provider, and structurally invalid data fail closed. Writes and deletion use the DSH credentials service's serialized interfaces: a deletion that acquires the lock first prevents refresh from starting, while a refresh already holding the lock completes before deletion becomes the final state. Credentials therefore cannot be resurrected after sign-out.
42
+
43
+ The Web settings page receives only sign-in methods, sanitized notices, an authorization-page URL, a verification code, and completion state. Access tokens, refresh tokens, complete grants, and complete provider errors do not cross the loopback RPC or enter command output.
44
+
45
+ ## AuthorizationBridge
46
+
47
+ The Host starts, cancels, and observes sign-in through the DSH authorization service. It uses the credentials service to inspect configured-state metadata or sign out. The RPC bounds concurrent attempts, long-poll waiters, event counts, string lengths, and accepted URLs. After login returns, the plugin checks cancellation again while the credentials service holds the serialized write lock for this record. That mutate callback is the cancellation linearization point: cancellation before it selects the grant prevents the write; after selection the write completes, and the plugin does not issue a compensating delete that could erase a newer sign-in already queued by another DSH process. A generation tracker that contains no credential material holds the commit phase through the matching `authorization/settled` event. The settings page and `/codex-login cancel` reject a post-linearization cancel in the same synchronous call stack and keep observing the final `authorized` state. Sign-out bypasses that barrier, aborts active attempts, and then explicitly deletes the credential through the same serialized record path, so an older in-progress write cannot resurrect the credential after sign-out completes. A completed attempt ID retained briefly for status retrieval cannot cancel a newer sign-in.
48
+
49
+ `/codex-login status|cancel|logout` uses the same boundary and prevents command input from entering conversation history.
50
+
51
+ ## SessionPreferences
52
+
53
+ `/codex` changes only the session that receives the command:
54
+
55
+ - `fast on|off` controls whether the priority service tier is requested and defaults to off;
56
+ - `transport` accepts `auto`, `sse`, `websocket`, or `websocket-cached` and defaults to `auto`;
57
+ - `reset` restores that session's defaults.
58
+
59
+ Preferences live in a capacity-bounded in-memory table that returns immutable snapshots; they do not change global provider settings. A failed Fast request is not replayed automatically on another service tier, avoiding duplicate tool side effects. Real account entitlement for this tier must be confirmed by release acceptance.
60
+
61
+ The raw DSH session ID is used only for preference lookup and message/replay provenance. The transport/cache session ID passed to pi-ai is namespaced with `dsh-codex:`. `/codex reset`, `agent/disposed`, and runtime disposal use pi-ai's public exact-session APIs to clear only this plugin's WebSocket connection, fallback, and debug state. They never invoke no-argument global cleanup and do not affect sessions owned by another in-process pi-ai consumer. The namespace enters only pi-ai stream options; it does not rewrite history messages or replay envelopes.
62
+
63
+ ## ModelEnablement
64
+
65
+ This plugin's settings page uses `llm.discoverModels` to display requestable models from the current pi-ai catalog and stores its selection under the `dsh-codex` settings namespace. The runtime registers only a direct discovery handler for that namespace, not a general configurable-provider directory, avoiding interference with DSH's general model editor.
66
+
67
+ The settings page requires at least one selected model. Selecting all removes the `models` override only when entries have no custom fields, allowing the directory to follow pi-ai version updates. Partial selections, extra fields, and custom parameters retain explicit configuration. Catalog filtering affects discovery only; exact hidden models remain resolvable, so older sessions are not invalidated merely because a model is hidden.
68
+
69
+ ## ImagePolicy
70
+
71
+ Optional settings resolve to one immutable policy with no optional numeric fields. The attachment contract receives only `{ maxPixels, maxBytes }`. Zero, negative, fractional, `NaN`, and unsafe integers fail at the configuration boundary.
72
+
73
+ Remote images extend the existing `read_image` tool through a `tools/execute` middleware. Local paths delegate unchanged. HTTP(S) URLs pass public-DNS validation, address pinning, per-hop redirect checks, one total timeout, a MIME allowlist, and byte limits both before and after decompression. Accepted content is committed to the DSH attachment store before the middleware returns the original tool's output schema.
74
+
75
+ Each plugin instance runs at most two remote-image jobs and queues at most 32; a full queue returns `TOO_MANY_REQUESTS`. Cancellation while queued removes the job, and cancellation during download aborts network work. Plugin disposal first closes the limiter, atomically rejects every queued job, and aborts active model-resolution or network work through a separate lifetime signal; Context cleanup waits for jobs that already entered `saveImage` to settle. The current public DSH `saveImage` interface has no `AbortSignal` or rollback contract, so cancellation after persistence begins guarantees only that success is not returned to the caller; it cannot guarantee deletion of an attachment that may already have been stored. The execution slot also remains occupied until `saveImage` settles. The bounded queue and lifecycle closure prevent new post-disposal work and unbounded memory growth.
76
+
77
+ ## FailureNormalizer and QuotaObserver
78
+
79
+ `FailureNormalizer` converts a DSH `LlmFailure`, including embedded structured JSON, into a stable classification. Only verifiable structured `code`/`type` values such as `AccountQuotaExceeded` or `insufficient_quota`, or narrowly scoped account-level text confirming exhausted usage, become `QUOTA`; an ordinary 429 remains `RATE_LIMIT`. Multiple embedded JSON objects retain independent provenance: reset, request ID, and status may come only from the failure's top level or one `error` envelope that independently proves quota, never by combining envelopes. pi-ai `0.82.1` collapses distinct 429 responses into one ChatGPT usage-limit message and no longer exposes the original code/body. That evidence-free result becomes non-retryable `QUOTA_OR_RATE_LIMIT` instead of being mislabeled as confirmed account quota.
80
+
81
+ `QUOTA`, `QUOTA_OR_RATE_LIMIT`, and confirmed transport failures are rebuilt as minimal failures containing a fixed sanitized message, code, and optional valid HTTP status/safe-character request ID. Arbitrary provider fields and WebSocket close reasons are never reflected. `QUOTA_OR_RATE_LIMIT` is not written to `QuotaObserver`: it fails without retry when no partial output exists and only preserves already safe plain text when partial output exists.
82
+
83
+ `QuotaObserver` records only successful completion, `QUOTA`, and a reset timestamp accepted by strict format and bounded-horizon checks. Snapshots have three states: `unknown`, `recent-success`, and `exhausted`. It does not poll an account, display a balance or percentage, or call undocumented plan-quota endpoints.
84
+
85
+ ## StreamResilience
86
+
87
+ This module runs on the `llm/stream` waterfall:
88
+
89
+ 1. forward provider chunks while tracking open blocks;
90
+ 2. apply narrow classification when a terminal error, directly thrown confirmed quota/usage-limit/transport, public `STREAM_CLOSED`/WebSocket failure, or EOF without a terminal chunk arrives;
91
+ 3. if plain text exists and no tool call was emitted, close open blocks, append a recovery notice, and finish with `stop`;
92
+ 4. if a tool call exists, including a tool-only stream with no text, return a non-retryable failure without replaying the complete request.
93
+
94
+ The module does not execute tools or add a hidden pre-stream retry loop. Only a pre-output `RATE_LIMIT`, `SERVER`, `TIMEOUT`, or confirmed `TRANSPORT` that matches the retry policy is eligible for at most two upper-layer retries. Directly thrown confirmed failures use the same sanitization and recovery path, while unrelated exceptions still propagate unchanged. Fast and transport selection never triggers service-tier downgrade or full-request replay.
95
+
96
+ ## Profile composition
97
+
98
+ `codex-community.patch.yml` inserts authorization and `dsh-codex` without modifying the general `llm-pi-ai` Cordis row. Other pi-ai providers remain available in the same profile.
99
+
100
+ This plugin's external route is `dsh-codex`, and its credential key is `dsh-codex/openai-codex`; routes and credential scopes belonging to the general plugin remain unchanged. If a user enables both Codex routes, the model selector shows two provider groups. Every DSH upgrade must rerun bundle coexistence, replay, and isolated-profile-load tests.
101
+
102
+ ## Other capability boundaries
103
+
104
+ - Session compaction uses DSH's built-in automatic compaction and `/compact`; this plugin does not rewrite the compaction protocol.
105
+ - Web search uses DSH tools and their corresponding credentials; it does not reuse ChatGPT OAuth.
106
+ - Image generation or editing requires a separate model, credentials, and billing boundary and is not provided by `0.0.1`.