dsh-code 0.7.0 → 0.9.0

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 (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
@@ -0,0 +1,85 @@
1
+ /** Permission-preset policy for pending and active TUI sessions. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
5
+
6
+ /** One selectable permission preset row for the /permission panel. */
7
+ export interface PermissionRow {
8
+ readonly id: string
9
+ readonly description?: string
10
+ }
11
+
12
+ /** Structural boundary over Harness permission presets; values stay service-owned. */
13
+ export interface PermissionPresetsService {
14
+ readonly names: readonly string[]
15
+ readonly defaultPreset: string
16
+ resolve(name: string): unknown
17
+ current(events: readonly SessionEvent[]): string
18
+ set(session: Session, preset: string): void
19
+ /** Client presentation metadata for one preset; may reject unknown names. */
20
+ optionOf?(name: string): { name: string; description?: string } | undefined
21
+ }
22
+
23
+ /** Read the optional Harness service without importing its runtime package. */
24
+ export function permissionPresetsFrom(ctx: Context): PermissionPresetsService | undefined {
25
+ return (ctx as unknown as { get(name: string): unknown }).get('permissionPresets') as PermissionPresetsService | undefined
26
+ }
27
+
28
+ /** Effective label for either an active session or the not-yet-created first one. */
29
+ export function effectivePermission(
30
+ service: PermissionPresetsService,
31
+ session: Session | undefined,
32
+ pending: string | undefined,
33
+ ): string {
34
+ return session === undefined ? pending ?? service.defaultPreset : service.current(session.events)
35
+ }
36
+
37
+ /** Validate a preset and write it only when a durable session already exists. */
38
+ export function selectPermission(
39
+ service: PermissionPresetsService,
40
+ session: Session | undefined,
41
+ preset: string,
42
+ ): string {
43
+ service.resolve(preset)
44
+ if (session !== undefined) service.set(session, preset)
45
+ return preset
46
+ }
47
+
48
+ /** Cycle table order from the active, pending, or configured-default value. */
49
+ export function cyclePermission(
50
+ service: PermissionPresetsService,
51
+ session: Session | undefined,
52
+ pending: string | undefined,
53
+ ): string {
54
+ if (service.names.length === 0) return ''
55
+ const at = service.names.indexOf(effectivePermission(service, session, pending))
56
+ const next = service.names[(at + 1) % service.names.length] ?? ''
57
+ return next === '' ? '' : selectPermission(service, session, next)
58
+ }
59
+
60
+ /** Materialize a pre-session choice after Harness creates the first session. */
61
+ export function applyPendingPermission(
62
+ service: PermissionPresetsService,
63
+ session: Session,
64
+ pending: string | undefined,
65
+ ): void {
66
+ if (pending !== undefined && effectivePermission(service, session, undefined) !== pending) {
67
+ selectPermission(service, session, pending)
68
+ }
69
+ }
70
+
71
+ /**
72
+ * List every switchable preset for the /permission panel, table order kept.
73
+ * Description lookup failures degrade to an undocumented row, never a failed
74
+ * panel load — `optionOf` rejects names its table no longer knows.
75
+ */
76
+ export function listPermissionRows(service: PermissionPresetsService): readonly PermissionRow[] {
77
+ return service.names.map((id) => {
78
+ if (service.optionOf === undefined) return { id }
79
+ try {
80
+ return { id, description: service.optionOf(id)?.description }
81
+ } catch {
82
+ return { id }
83
+ }
84
+ })
85
+ }
package/src/presets.ts CHANGED
@@ -45,6 +45,18 @@ export function resolvePreset(session: Pick<Session, 'header' | 'events'>): stri
45
45
  return session.header.agentPreset ?? 'standard'
46
46
  }
47
47
 
48
+ /** Resolve a pre-session choice, or recompose an active blank Agent. */
49
+ export async function selectPreset(
50
+ service: AgentPresetsService,
51
+ agent: Agent | undefined,
52
+ presetId: string,
53
+ ): Promise<PresetRow> {
54
+ if (agent !== undefined) return switchPreset(service, agent, presetId)
55
+ const preset = await service.resolve(presetId)
56
+ if (preset.broken !== undefined) throw new Error(preset.broken)
57
+ return preset
58
+ }
59
+
48
60
  /** Recompose atomically from the caller's perspective, logging only success. */
49
61
  export async function switchPreset(
50
62
  service: AgentPresetsService,
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Provider settings adapter for the TUI `/model` provider-management panel:
3
+ * the same-process equivalent of the web host's Models page join
4
+ * (`packages/client/ui-settings-models`), reading the advisory `ctx.llm`
5
+ * registry, the redacted `ctx.settings` descriptors, and the value-free
6
+ * `ctx.credentials` facts directly. Secrets never cross this module: settings
7
+ * are read with `redactSecrets: true`, credentials are only ever described
8
+ * (never resolved), and every message is single-line without embedding key
9
+ * data.
10
+ *
11
+ * @module @deepseek-ai/dsh-tui/provider-settings
12
+ */
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import { normalizeApiKey } from '@deepseek-ai/dsh-llm'
16
+
17
+ /* ------------------------------------------------------------------ *
18
+ * Structural service faces. Branding is erased at runtime, so credential
19
+ * refs and settings namespaces are plain strings here; they originate only
20
+ * from trusted settings descriptors or the validated derivation below.
21
+ * Declaring the faces locally keeps this a dependency-free adapter over
22
+ * `ctx.get(...)` — the settings and credentials packages are never imported.
23
+ * ------------------------------------------------------------------ */
24
+
25
+ /** The subset of the `llm` service this module reads. */
26
+ interface LlmFace {
27
+ /** Registered provider routes with a live adapter (`listProviders()`). */
28
+ listProviders(): readonly { readonly id: string; readonly name: string }[]
29
+ /** Declared configurable-provider directory; absent on an older service. */
30
+ listConfigurableProviders?(): readonly {
31
+ readonly provider: string
32
+ readonly displayName: string
33
+ readonly settingsNs: string
34
+ readonly settingsPath: readonly string[]
35
+ readonly declared?: boolean
36
+ }[]
37
+ }
38
+
39
+ /** One redacted settings descriptor (subset of `SettingsDescriptor`). */
40
+ interface SettingsDescriptorFace {
41
+ readonly ns: string
42
+ readonly value: unknown
43
+ readonly revision: number
44
+ readonly base?: unknown
45
+ readonly user?: unknown
46
+ }
47
+
48
+ /** One path-addressed edit to a stored section (subset of `SettingsPathOp`). */
49
+ interface SettingsPathOpFace {
50
+ readonly op: 'set' | 'unset'
51
+ readonly path: readonly string[]
52
+ readonly value?: unknown
53
+ }
54
+
55
+ /** The subset of the `settings` service this module reads and writes. */
56
+ interface SettingsFace {
57
+ readonly writable: boolean
58
+ describe(options?: { readonly redactSecrets?: boolean }): readonly SettingsDescriptorFace[]
59
+ mutate(ns: string, ops: readonly SettingsPathOpFace[], expectedRevision?: number): Promise<void>
60
+ }
61
+
62
+ /** Value-free facts about one credential reference (subset of `CredentialInfo`). */
63
+ interface CredentialFactsFace {
64
+ readonly configured: boolean
65
+ readonly source?: string
66
+ readonly writable: boolean
67
+ }
68
+
69
+ /** Event subscription face used for the official Models invalidation trio. */
70
+ interface ProviderEventsFace {
71
+ on(event: string, listener: (...args: unknown[]) => void): () => void
72
+ }
73
+
74
+ /** The subset of the `credentials` service this module reads and writes. */
75
+ interface CredentialsFace {
76
+ describe(ref: string): Promise<CredentialFactsFace>
77
+ set(ref: string, value: string): Promise<void>
78
+ unset(ref: string): Promise<void>
79
+ }
80
+
81
+ /* ------------------------------------------------------------------ *
82
+ * Pure helpers.
83
+ * ------------------------------------------------------------------ */
84
+
85
+ /** Human text for a rejection value (mirrors the web page's `messageOf`). */
86
+ function messageOf(error: unknown): string {
87
+ return error instanceof Error ? error.message : String(error)
88
+ }
89
+
90
+ /** Collapse every whitespace/control run to one space so a notice stays one line. */
91
+ function singleLine(message: string): string {
92
+ return message.replace(/[\u0000-\u001F\u007F]/g, ' ').replace(/\s+/g, ' ').trim()
93
+ }
94
+
95
+ /** Keep a misbehaving credential provider from reflecting the submitted secret. */
96
+ function credentialWriteMessage(error: unknown, secret: string): string {
97
+ const message = singleLine(messageOf(error))
98
+ return message.includes(secret) ? 'credentials service rejected the API key' : message
99
+ }
100
+
101
+ /** Obvious shell-assignment paste; mirrors the official Web Models editor. */
102
+ const ENV_ASSIGNMENT = /^[A-Z][A-Z0-9_]*=[^=]/
103
+
104
+ /** Whether the whole draft is wrapped in one matching quote pair. */
105
+ function hasWrappingQuotes(value: string): boolean {
106
+ const first = value[0]
107
+ return (first === '"' || first === '\'' || first === '`') && value.length > 1 && value.endsWith(first)
108
+ }
109
+
110
+ /** Read the value at a path through plain objects; undefined when any segment misses. */
111
+ function getPath(value: unknown, path: readonly string[]): unknown {
112
+ let current = value
113
+ for (const segment of path) {
114
+ if (typeof current !== 'object' || current === null) return undefined
115
+ current = (current as Record<string, unknown>)[segment]
116
+ }
117
+ return current
118
+ }
119
+
120
+ /** Whether a path resolves to a defined value (the empty path reads the root). */
121
+ function hasPath(value: unknown, path: readonly string[]): boolean {
122
+ return path.length === 0 ? value !== undefined : getPath(value, path) !== undefined
123
+ }
124
+
125
+ /** The credential reference a resolved profile names (its `apiKeyEnv` field). */
126
+ function profileRefOf(profile: unknown): string | undefined {
127
+ if (typeof profile !== 'object' || profile === null) return undefined
128
+ const ref = (profile as { readonly apiKeyEnv?: unknown }).apiKeyEnv
129
+ return typeof ref === 'string' && ref.length > 0 ? ref : undefined
130
+ }
131
+
132
+ /* ------------------------------------------------------------------ *
133
+ * Exported contracts.
134
+ * ------------------------------------------------------------------ */
135
+
136
+ /**
137
+ * The conventional credential reference for a provider route: `<ROUTE>_API_KEY`
138
+ * with the route uppercased and every non-alphanumeric run collapsed to one
139
+ * underscore — the exact derivation the official Models page uses
140
+ * (`deriveKeyRef` in `ui-settings-models`), so a key saved here is found there.
141
+ * @param provider - provider route id (e.g. `pi-ai`, `minimax-cn`).
142
+ * @returns the derived reference name (e.g. `PI_AI_API_KEY`).
143
+ */
144
+ export function deriveCredentialRef(provider: string): string {
145
+ return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
146
+ }
147
+
148
+ /** Value-free facts about one credential reference — never the value. */
149
+ export interface ProviderCredentialFacts {
150
+ /** Whether the reference currently resolves to a stored value. */
151
+ readonly configured: boolean
152
+ /** Source layer supplying the value; absent while unconfigured. */
153
+ readonly source?: string
154
+ /** Whether a write through this panel would currently succeed. */
155
+ readonly writable: boolean
156
+ }
157
+
158
+ /**
159
+ * One row's credential state: value-free facts once the reference was
160
+ * described, a bounded error when that describe failed (the row itself is
161
+ * never dropped), or `undefined` when the row names no reference to describe —
162
+ * an unmanaged active provider, a dormant route, or a profile authenticating
163
+ * through the provider's own path.
164
+ */
165
+ export type ProviderCredentialView =
166
+ | ({ readonly kind: 'facts' } & ProviderCredentialFacts)
167
+ | { readonly kind: 'error'; readonly message: string }
168
+
169
+ /**
170
+ * One provider row in the TUI provider-management panel: the configurable
171
+ * directory entry joined with its settings profile and credential facts.
172
+ * Every mutation below addresses this row, and the caller passes the row back
173
+ * after re-loading so the revision/ref facts are current.
174
+ */
175
+ export interface ProviderTargetView {
176
+ /** Provider route id (`GenerateOptions.provider`). */
177
+ readonly provider: string
178
+ /** Human-readable provider name. */
179
+ readonly displayName: string
180
+ /** Whether an adapter currently serves this route. */
181
+ readonly active: boolean
182
+ /** User-settings namespace whose section configures this provider; '' when unmanaged. */
183
+ readonly settingsNs: string
184
+ /** Path from that section's root to this provider's profile; [] when the whole section is the profile. */
185
+ readonly settingsPath: readonly string[]
186
+ /** Revision of the owning settings section at load (0 when no namespace resolved). */
187
+ readonly settingsRevision: number
188
+ /** Whether the resolved profile exists (the whole section, or at `settingsPath`). */
189
+ readonly configured: boolean
190
+ /** Whether only the user settings layer carries the profile, so removal restores the base. */
191
+ readonly removable: boolean
192
+ /** The credential reference the resolved profile names, when one does. */
193
+ readonly credentialRef?: string
194
+ /** The conventional reference a save uses for a dormant or ref-less profile. */
195
+ readonly suggestedRef: string
196
+ /** Credential facts, a bounded describe error, or undefined when there is no ref to describe. */
197
+ readonly credential: ProviderCredentialView | undefined
198
+ /** The owning adapter reports this route as hand-declared (absent when it draws no distinction). */
199
+ readonly declared?: boolean
200
+ }
201
+
202
+ /** The resolved provider/settings/credential join. */
203
+ export interface ProviderSettingsDirectory {
204
+ /** Provider rows: configurable-directory order first, active-unmanaged rows after. */
205
+ readonly rows: readonly ProviderTargetView[]
206
+ /** Whether the settings provider accepts writes (mirrors the web page's flag). */
207
+ readonly writable: boolean
208
+ /** Non-fatal join failures (settings/directory reads), for a degradation notice. */
209
+ readonly failures: readonly string[]
210
+ }
211
+
212
+ /** Events that invalidate the official Models provider/settings/credential join. */
213
+ const PROVIDER_SETTINGS_EVENTS = [
214
+ 'credentials/updated',
215
+ 'settings/document-updated',
216
+ 'llm/adapters-updated',
217
+ ] as const
218
+
219
+ /** Subscribe to the same provider-directory invalidations as the official Web Models page. */
220
+ export function subscribeProviderSettings(ctx: Context, listener: () => void): () => void {
221
+ const events = ctx as unknown as ProviderEventsFace
222
+ const disposers = PROVIDER_SETTINGS_EVENTS.map(event => events.on(event, () => listener()))
223
+ return () => {
224
+ for (const dispose of disposers) dispose()
225
+ }
226
+ }
227
+
228
+ /** A single-line, bounded error from the provider-management adapter. */
229
+ export class ProviderSettingsError extends Error {
230
+ constructor(message: string) {
231
+ super(message)
232
+ this.name = 'ProviderSettingsError'
233
+ }
234
+ }
235
+
236
+ /* ------------------------------------------------------------------ *
237
+ * Load.
238
+ * ------------------------------------------------------------------ */
239
+
240
+ /**
241
+ * Join the configurable-provider directory, the redacted settings
242
+ * namespaces, and the referenced credentials into panel rows, web-parity:
243
+ * - directory entries merge with `listProviders()` to mark each live or
244
+ * dormant, and routes registered without a directory declaration appear as
245
+ * read-only/unmanaged rows (no settings address);
246
+ * - a whole-section entry is configured whenever its namespace resolves;
247
+ * a path-addressed one only when the profile resolves there;
248
+ * - a row is removable when the user layer alone carries its profile;
249
+ * - only refs named by resolved profiles are described, and a per-ref failure
250
+ * degrades to that row's bounded error instead of losing it.
251
+ * Absent `settings`/`credentials` services are tolerated the same way.
252
+ * @param ctx - context carrying the `llm` service (settings/credentials optional).
253
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
254
+ */
255
+ export async function loadProviderSettings(ctx: Context): Promise<ProviderSettingsDirectory> {
256
+ const llm = ctx.get('llm') as LlmFace | undefined
257
+ if (llm === undefined) return { rows: [], writable: false, failures: [] }
258
+ // Call listProviders/listConfigurableProviders AS METHODS: destructured off
259
+ // the service they lose `this` and throw on the first read (same lesson as
260
+ // loadModelDirectory).
261
+ const registered = llm.listProviders()
262
+ const failures: string[] = []
263
+ const directoryEntries: Array<{
264
+ provider: string
265
+ displayName: string
266
+ settingsNs: string
267
+ settingsPath: readonly string[]
268
+ declared?: boolean
269
+ }> = []
270
+ if (llm.listConfigurableProviders !== undefined) {
271
+ try {
272
+ directoryEntries.push(...llm.listConfigurableProviders())
273
+ } catch (error) {
274
+ failures.push(`configurable-provider directory failed: ${singleLine(messageOf(error))}`)
275
+ }
276
+ }
277
+ const settings = ctx.get('settings') as SettingsFace | undefined
278
+ let descriptors: readonly SettingsDescriptorFace[] = []
279
+ let writable = false
280
+ if (settings !== undefined) {
281
+ try {
282
+ descriptors = settings.describe({ redactSecrets: true })
283
+ writable = settings.writable === true
284
+ } catch (error) {
285
+ failures.push(`settings describe failed: ${singleLine(messageOf(error))}`)
286
+ }
287
+ }
288
+ const namespaces = new Map(descriptors.map(descriptor => [descriptor.ns, descriptor] as const))
289
+ const active = new Set(registered.map(provider => provider.id))
290
+ const declared = new Set(directoryEntries.map(entry => entry.provider))
291
+ // Directory order first, then registered-but-undeclared routes (web parity:
292
+ // they exist and serve models, just with no settings address).
293
+ const bases: Array<{
294
+ provider: string
295
+ displayName: string
296
+ active: boolean
297
+ settingsNs: string
298
+ settingsPath: readonly string[]
299
+ declared?: boolean
300
+ }> = [
301
+ ...directoryEntries.map(entry => ({
302
+ provider: entry.provider,
303
+ displayName: entry.displayName,
304
+ active: active.has(entry.provider),
305
+ settingsNs: entry.settingsNs,
306
+ settingsPath: entry.settingsPath,
307
+ ...entry.declared === undefined ? {} : { declared: entry.declared },
308
+ })),
309
+ ...registered
310
+ .filter(provider => !declared.has(provider.id))
311
+ .map(provider => ({
312
+ provider: provider.id,
313
+ displayName: provider.name,
314
+ active: true,
315
+ settingsNs: '',
316
+ settingsPath: [] as readonly string[],
317
+ })),
318
+ ]
319
+ const rows: Array<Omit<ProviderTargetView, 'credential'>> = bases.map((base) => {
320
+ const namespace = base.settingsNs.length === 0 ? undefined : namespaces.get(base.settingsNs)
321
+ const profile = namespace === undefined
322
+ ? undefined
323
+ : base.settingsPath.length === 0
324
+ ? namespace.value
325
+ : getPath(namespace.value, base.settingsPath)
326
+ const configured = namespace !== undefined
327
+ && (base.settingsPath.length === 0 || profile !== undefined)
328
+ const removable = namespace !== undefined
329
+ && base.settingsPath.length > 0
330
+ && hasPath(namespace.user, base.settingsPath)
331
+ && !hasPath(namespace.base, base.settingsPath)
332
+ const credentialRef = profileRefOf(profile)
333
+ return {
334
+ provider: base.provider,
335
+ displayName: base.displayName,
336
+ active: base.active,
337
+ settingsNs: base.settingsNs,
338
+ settingsPath: base.settingsPath,
339
+ settingsRevision: namespace?.revision ?? 0,
340
+ configured,
341
+ removable,
342
+ ...credentialRef === undefined ? {} : { credentialRef },
343
+ suggestedRef: deriveCredentialRef(base.provider),
344
+ ...base.declared === undefined ? {} : { declared: base.declared },
345
+ }
346
+ })
347
+ const refs = [...new Set(rows.flatMap(row => row.credentialRef === undefined ? [] : [row.credentialRef]))]
348
+ const credentialViews = new Map<string, ProviderCredentialView>()
349
+ const credentials = ctx.get('credentials') as CredentialsFace | undefined
350
+ if (refs.length > 0) {
351
+ if (credentials === undefined) {
352
+ for (const ref of refs) {
353
+ credentialViews.set(ref, { kind: 'error', message: 'credentials service is unavailable' })
354
+ }
355
+ } else {
356
+ await Promise.all(refs.map(async (ref) => {
357
+ try {
358
+ const facts = await credentials.describe(ref)
359
+ credentialViews.set(ref, {
360
+ kind: 'facts',
361
+ configured: facts.configured,
362
+ writable: facts.writable,
363
+ ...facts.source === undefined ? {} : { source: facts.source },
364
+ })
365
+ } catch (error) {
366
+ credentialViews.set(ref, { kind: 'error', message: singleLine(messageOf(error)) })
367
+ }
368
+ }))
369
+ }
370
+ }
371
+ return {
372
+ rows: rows.map(row => ({
373
+ ...row,
374
+ credential: row.credentialRef === undefined
375
+ ? undefined
376
+ : credentialViews.get(row.credentialRef) ?? { kind: 'error', message: 'credential describe returned no view' },
377
+ })),
378
+ writable,
379
+ failures,
380
+ }
381
+ }
382
+
383
+ /* ------------------------------------------------------------------ *
384
+ * Writes. Official web ordering is kept: settings.mutate first whenever a
385
+ * profile/path or apiKeyEnv must be materialized, then credentials.set; a
386
+ * removal unsets the managed credential before the profile. All operations
387
+ * are idempotent, so re-running one after a partial failure is safe.
388
+ * ------------------------------------------------------------------ */
389
+
390
+ /**
391
+ * Store a provider API key, web-parity: validate with `normalizeApiKey`
392
+ * (single-line, actionable errors that never echo the key), materialize the
393
+ * profile/`apiKeyEnv` through `settings.mutate` first when the resolved
394
+ * profile names no reference (dormant route or ref-less profile), then store
395
+ * under the trusted named ref or the derived conventional ref. An existing
396
+ * whole-section DeepSeek whose resolved profile already names
397
+ * `DEEPSEEK_API_KEY` needs no settings mutation. Env-supplied read-only keys
398
+ * are refused before any service call.
399
+ * @param ctx - context carrying `settings` (when materializing) and `credentials`.
400
+ * @param target - the joined row to write through.
401
+ * @param rawKey - the key exactly as typed; surrounding whitespace is trimmed.
402
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
403
+ */
404
+ export async function saveProviderCredential(ctx: Context, target: ProviderTargetView, rawKey: string): Promise<void> {
405
+ const trimmed = rawKey.trim()
406
+ if (ENV_ASSIGNMENT.test(trimmed) || hasWrappingQuotes(trimmed)) {
407
+ throw new ProviderSettingsError('paste only the API key, without an environment-variable name or wrapping quotes')
408
+ }
409
+ const checked = normalizeApiKey(rawKey)
410
+ if (!checked.ok) {
411
+ throw new ProviderSettingsError(checked.reason === 'empty'
412
+ ? 'the API key is empty after trimming surrounding whitespace'
413
+ : 'the API key contains characters an HTTP header cannot carry; type a plain printable-ASCII key')
414
+ }
415
+ if (target.settingsNs.length === 0) {
416
+ throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; configure it in settings.yaml`)
417
+ }
418
+ if (target.credential?.kind === 'facts' && target.credential.writable === false) {
419
+ throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead of overwriting it here`)
420
+ }
421
+ const credentials = ctx.get('credentials') as CredentialsFace | undefined
422
+ if (credentials === undefined) {
423
+ throw new ProviderSettingsError('credentials service is unavailable; cannot store the API key')
424
+ }
425
+ const ref = target.credentialRef ?? deriveCredentialRef(target.provider)
426
+ if (target.credentialRef === undefined) {
427
+ const settings = ctx.get('settings') as SettingsFace | undefined
428
+ if (settings === undefined) {
429
+ throw new ProviderSettingsError('settings service is unavailable; cannot materialize the credential reference')
430
+ }
431
+ try {
432
+ await settings.mutate(target.settingsNs, [{ op: 'set', path: [...target.settingsPath, 'apiKeyEnv'], value: ref }])
433
+ } catch (error) {
434
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
435
+ }
436
+ }
437
+ try {
438
+ await credentials.set(ref, checked.value)
439
+ } catch (error) {
440
+ throw new ProviderSettingsError(credentialWriteMessage(error, checked.value))
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Remove the currently named credential without touching the provider
446
+ * profile. Only the resolved profile's own reference is unset; a dormant or
447
+ * ref-less row (nothing to remove), an already-absent key, and an
448
+ * env-supplied read-only key are rejected safely before any service call.
449
+ * @param ctx - context carrying the `credentials` service.
450
+ * @param target - the joined row whose named credential to unset.
451
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
452
+ */
453
+ export async function unsetProviderCredential(ctx: Context, target: ProviderTargetView): Promise<void> {
454
+ const ref = target.credentialRef
455
+ if (ref === undefined) {
456
+ throw new ProviderSettingsError(`provider "${target.provider}" names no credential reference to remove`)
457
+ }
458
+ const facts = target.credential
459
+ if (facts?.kind === 'facts' && facts.configured === false) {
460
+ throw new ProviderSettingsError(`provider "${target.provider}" has no configured credential to remove`)
461
+ }
462
+ if (facts?.kind === 'facts' && facts.writable === false) {
463
+ throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead`)
464
+ }
465
+ const credentials = ctx.get('credentials') as CredentialsFace | undefined
466
+ if (credentials === undefined) {
467
+ throw new ProviderSettingsError('credentials service is unavailable; cannot remove the API key')
468
+ }
469
+ try {
470
+ await credentials.unset(ref)
471
+ } catch (error) {
472
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Remove a user-added provider profile, web-parity: only `removable` rows may
478
+ * be removed; a page-managed credential — the derived ref, configured and
479
+ * writable — is unset first (so a second-step failure leaves the row visible
480
+ * and the operation retryable), then `settings.mutate` unsets
481
+ * `target.settingsPath`. Both steps are idempotent. A hand-named credential
482
+ * ref may be shared elsewhere and is left alone.
483
+ * @param ctx - context carrying `credentials` and `settings`.
484
+ * @param target - the joined row to remove.
485
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
486
+ */
487
+ export async function removeProviderSettings(ctx: Context, target: ProviderTargetView): Promise<void> {
488
+ if (!target.removable) {
489
+ throw new ProviderSettingsError(`provider "${target.provider}" is not removable from the user settings layer`)
490
+ }
491
+ if (target.settingsNs.length === 0) {
492
+ throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings profile to remove`)
493
+ }
494
+ const managedRef = target.credentialRef === target.suggestedRef
495
+ && target.credential?.kind === 'facts'
496
+ && target.credential.configured === true
497
+ && target.credential.writable === true
498
+ ? target.credentialRef
499
+ : undefined
500
+ if (managedRef !== undefined) {
501
+ const credentials = ctx.get('credentials') as CredentialsFace | undefined
502
+ if (credentials === undefined) {
503
+ throw new ProviderSettingsError('credentials service is unavailable; cannot remove the managed API key')
504
+ }
505
+ try {
506
+ await credentials.unset(managedRef)
507
+ } catch (error) {
508
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
509
+ }
510
+ }
511
+ const settings = ctx.get('settings') as SettingsFace | undefined
512
+ if (settings === undefined) {
513
+ throw new ProviderSettingsError('settings service is unavailable; cannot remove the provider profile')
514
+ }
515
+ try {
516
+ await settings.mutate(target.settingsNs, [{ op: 'unset', path: [...target.settingsPath] }])
517
+ } catch (error) {
518
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
519
+ }
520
+ }
package/src/questions.ts CHANGED
@@ -28,6 +28,8 @@ export interface PendingQuestion {
28
28
  resolve(answers: AskUserQuestionAnswer): void
29
29
  /** Reject the provider promise as aborted (also used for Esc cancel). */
30
30
  reject(error: Error): void
31
+ /** Detach the request's abort listener once the question settles (internal). */
32
+ detachAbort?(): void
31
33
  }
32
34
 
33
35
  /** The pending-question snapshot the renderer subscribes to. */
@@ -81,11 +83,6 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
81
83
  service.registerProvider({
82
84
  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
83
85
  return new Promise((resolve, reject) => {
84
- const pending: PendingQuestion = {
85
- request,
86
- resolve,
87
- reject,
88
- }
89
86
  // Abort settles through the same channel as an Esc cancel: the
90
87
  // owning tool/step died, so the answer must not linger.
91
88
  const onAbort = (): void => {
@@ -99,6 +96,17 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
99
96
  }
100
97
  reject(ABORT_ERROR)
101
98
  }
99
+ // Detach on every settle so an answered/cancelled question never
100
+ // retains a listener on the owning tool call's signal.
101
+ const detachAbort = (): void => {
102
+ if (request.signal !== undefined) request.signal.removeEventListener('abort', onAbort)
103
+ }
104
+ const pending: PendingQuestion = {
105
+ request,
106
+ resolve,
107
+ reject,
108
+ detachAbort,
109
+ }
102
110
  if (request.signal?.aborted === true) {
103
111
  reject(ABORT_ERROR)
104
112
  return
@@ -129,6 +137,7 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
129
137
  if (active !== pending) return
130
138
  active = undefined
131
139
  set({ pending: undefined })
140
+ pending.detachAbort?.()
132
141
  pending.resolve(answers)
133
142
  advance()
134
143
  },
@@ -136,6 +145,7 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
136
145
  if (active !== pending) return
137
146
  active = undefined
138
147
  set({ pending: undefined })
148
+ pending.detachAbort?.()
139
149
  pending.reject(ABORT_ERROR)
140
150
  advance()
141
151
  },