dsh-code 0.6.1 → 0.8.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 (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
package/src/mentions.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Workspace @mention support: file candidates from a bounded async scan of
3
- * the session cwd, session candidates from the opt-in `sessionReferenceResolver`
4
- * service, and submission preparation through its `prepare()` API. Picked
5
- * session mentions land as canonical `@[label](dsh-session:…)` tokens; on
6
- * submit the text is parsed back into readable `@label` text plus structured
7
- * references, snapshots are injected via `agent.inject()` before the readable
8
- * message wakes the driver (`followup` idle, `steer` running) exactly the
9
- * upstream README's wiring.
2
+ * Workspace @mention support: file and directory candidates from a bounded
3
+ * async scan of the session cwd, session candidates from the opt-in
4
+ * `sessionReferenceResolver` service, and submission preparation through its
5
+ * `prepare()` API. Picked session mentions land as canonical
6
+ * `@[label](dsh-session:…)` tokens; on submit the text is parsed back into
7
+ * readable `@label` text plus structured references, snapshots are injected
8
+ * via `agent.inject()` before the readable message wakes the driver
9
+ * (`followup` idle, `steer` running) — exactly the upstream README's wiring.
10
+ *
11
+ * Harness exposes no workspace-file mention service (only session references
12
+ * plus a post-hoc produced-file linker), so the file index is the lightweight
13
+ * bounded scan below, kept deliberately smaller than Codex's streaming
14
+ * gitignore-aware walker.
10
15
  *
11
16
  * @module @deepseek-ai/dsh-code/mentions
12
17
  */
@@ -57,8 +62,17 @@ export interface PreparedMention {
57
62
  const SKIP_DIRS = new Set(['.git', 'node_modules', 'lib', 'dist', 'out', '.omc', 'coverage'])
58
63
  const MAX_FILES = 4000
59
64
  const MAX_DEPTH = 12
65
+ /** Empty-query default rows: proves the index exists without typing (Codex's
66
+ * popups show something on a bare sigil too). */
67
+ const EMPTY_QUERY_ROWS = 20
60
68
 
61
- /** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
69
+ /**
70
+ * Bounded async BFS scan of a workspace; unreadable entries are skipped.
71
+ * Both files and directories are indexed (directories insert with a trailing
72
+ * slash), mirroring Codex's `MatchType::{File,Directory}` index. Dotfiles and
73
+ * the {@link SKIP_DIRS} list are excluded, which is a coarser filter than
74
+ * Codex's gitignore-aware walker but stays dependency-free and bounded.
75
+ */
62
76
  export async function scanWorkspaceFiles(root: string, signal?: AbortSignal): Promise<readonly FileCandidate[]> {
63
77
  const found: FileCandidate[] = []
64
78
  const pending: Array<{ absolute: string; relative: string; depth: number }> = [{ absolute: root, relative: '', depth: 0 }]
@@ -78,6 +92,7 @@ export async function scanWorkspaceFiles(root: string, signal?: AbortSignal): Pr
78
92
  const relative = current.relative === '' ? entry.name : `${current.relative}/${entry.name}`
79
93
  if (entry.isDirectory()) {
80
94
  if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue
95
+ found.push({ path: relative, kind: 'directory' })
81
96
  pending.push({ absolute: join(current.absolute, entry.name), relative, depth: current.depth + 1 })
82
97
  } else if (entry.isFile()) {
83
98
  found.push({ path: relative, kind: 'file' })
@@ -111,7 +126,7 @@ function scoreFile(path: string, query: string): number {
111
126
 
112
127
  /** The mention API the input editor and the runner share. */
113
128
  export interface MentionsApi {
114
- /** Scanned workspace files, cached across one session. */
129
+ /** Scanned workspace files and directories, cached across one session. */
115
130
  files(): Promise<readonly FileCandidate[]>
116
131
  /** Ranked menu candidates for the typed `@` query. */
117
132
  candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
@@ -129,49 +144,64 @@ export interface MentionsApi {
129
144
  /**
130
145
  * Create the mention API for one agent's workspace. A missing
131
146
  * session-reference service degrades to file mentions only (the scan still
132
- * works); `prepare` then passes text through untouched.
147
+ * works); `prepare` then passes text through untouched. An undefined agent
148
+ * (a bare launch before any session exists) also degrades to file-only
149
+ * mentions, so `@` file completion works before the first message.
150
+ *
151
+ * `files` is hoisted into the closure so `candidates` never reaches for
152
+ * `this` — the runner hands `mentions.candidates` to the input editor as a
153
+ * detached callback, and a `this`-bound method would throw on every `@` key.
133
154
  * @param ctx - context carrying the optional `sessionReferenceResolver`.
134
155
  * @param agent - the session owner; excluded from its own candidates.
135
156
  * @param cwd - workspace root to scan.
136
157
  */
137
- export function createMentions(ctx: Context, agent: Agent, cwd: string): MentionsApi {
158
+ export function createMentions(ctx: Context, agent: Agent | undefined, cwd: string): MentionsApi {
138
159
  const resolver = ctx.get('sessionReferenceResolver')
160
+ const sessionCapable = agent !== undefined && resolver !== undefined
139
161
  let filesPromise: Promise<readonly FileCandidate[]> | undefined
162
+ const files = (): Promise<readonly FileCandidate[]> => {
163
+ filesPromise ??= scanWorkspaceFiles(cwd)
164
+ return filesPromise
165
+ }
140
166
 
141
167
  return {
142
- files(): Promise<readonly FileCandidate[]> {
143
- filesPromise ??= scanWorkspaceFiles(cwd)
144
- return filesPromise
145
- },
168
+ files,
146
169
  async candidates(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]> {
147
170
  const needle = query.trim()
148
- const [files, sessions] = await Promise.all([
149
- this.files(),
150
- resolver === undefined
151
- ? Promise.resolve([])
152
- : resolver.listCandidates(agent, needle, 10, signal).catch(() => []),
171
+ const [scanned, sessions] = await Promise.all([
172
+ files(),
173
+ sessionCapable && needle !== '' && agent !== undefined
174
+ ? resolver!.listCandidates(agent, needle, 10, signal).catch(() => [] as readonly SessionReferenceCandidate[])
175
+ : Promise.resolve([] as readonly SessionReferenceCandidate[]),
153
176
  ])
154
- const fileRows: MentionCandidate[] = files
155
- .filter(candidate => scoreFile(candidate.path, needle) > 0)
156
- .sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle))
157
- .slice(0, 20)
177
+ // A bare `@` lists the first path-sorted entries so the menu is live
178
+ // before any typing; a non-empty needle ranks by the Codex-shaped score.
179
+ const fileRows: MentionCandidate[] = (needle === ''
180
+ ? scanned.slice(0, EMPTY_QUERY_ROWS)
181
+ : scanned
182
+ .filter(candidate => scoreFile(candidate.path, needle) > 0)
183
+ .sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle))
184
+ .slice(0, 20))
158
185
  .map(candidate => ({
159
186
  label: candidate.path,
160
187
  description: candidate.kind === 'directory' ? 'Folder' : 'File',
161
188
  kind: candidate.kind,
162
189
  }))
190
+ // Sessions join only with a typed needle and always AFTER the file
191
+ // rows: `@` is a file mention first (Codex's semantics), and session
192
+ // references are the secondary vocabulary.
163
193
  const sessionRows: MentionCandidate[] = sessions.map(candidate => ({
164
194
  label: formatSessionReferenceMention(candidate),
165
195
  description: `Session · ${candidate.cwd ?? '(no cwd)'}`,
166
196
  kind: 'session',
167
197
  }))
168
- return [...sessionRows, ...fileRows]
198
+ return [...fileRows, ...sessionRows]
169
199
  },
170
200
  parse(text: string): ParsedSessionReferenceText {
171
201
  return parseSessionReferenceText(text)
172
202
  },
173
203
  async prepare(parsed: ParsedSessionReferenceText, signal?: AbortSignal): Promise<PreparedMention> {
174
- if (parsed.references.length === 0 || resolver === undefined) {
204
+ if (parsed.references.length === 0 || resolver === undefined || agent === undefined) {
175
205
  return { text: parsed.text, references: parsed.references }
176
206
  }
177
207
  const prepared = await resolver.prepare(
package/src/models.ts CHANGED
@@ -1,66 +1,200 @@
1
- /**
2
- * Model directory for the `/model` panel: the in-process equivalent of the
3
- * web host's model catalog (`buildModelCatalog`), reading the advisory
4
- * `ctx.llm` registry directly. Catalog membership is advisory — a route
5
- * serving a model it stopped advertising stays usable — so selection never
6
- * fails on catalog absence alone.
7
- *
8
- * @module @deepseek-ai/dsh-tui/models
9
- */
10
-
11
- import type { Context } from '@deepseek-ai/cordis'
12
- import type { LlmModelInfo } from '@deepseek-ai/dsh-llm'
13
-
14
- /** One selectable row in the `/model` panel. */
15
- export interface ModelRow {
16
- /** Registered provider route. */
17
- provider: string
18
- /** Display name of the provider route. */
19
- providerName: string
20
- /** Provider-owned model id. */
21
- model: string
22
- /** Human-readable model name. */
23
- modelName: string
24
- }
25
-
26
- /** The resolved directory: rows plus per-provider discovery failures. */
27
- export interface ModelDirectory {
28
- /** Advisory rows, provider-major in registry order. */
29
- rows: readonly ModelRow[]
30
- /** Provider ids whose model listing failed; those providers contribute no rows. */
31
- failures: readonly string[]
32
- }
33
-
34
- /**
35
- * Load the selectable model directory from the live `ctx.llm` registry.
36
- * Providers are listed synchronously; each provider's models are discovered
37
- * with a bounded parallel fan-out whose failures degrade to that provider
38
- * contributing no rows (mirrors the web catalog's per-provider failures).
39
- * @param ctx - context carrying the `llm` service.
40
- * @returns the resolved directory; empty rows when `llm` is unavailable.
41
- */
42
- export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory> {
43
- const llm = ctx.get('llm')
44
- if (llm === undefined) return { rows: [], failures: [] }
45
- const providers = llm.listProviders()
46
- const listed = await Promise.all(providers.map(async (provider) => {
47
- try {
48
- const models: readonly LlmModelInfo[] = await llm.listModels(provider.id)
49
- return {
50
- provider: provider.id,
51
- providerName: provider.name,
52
- models: models.map(model => ({
53
- provider: provider.id,
54
- providerName: provider.name,
55
- model: model.id,
56
- modelName: model.name,
57
- })),
58
- }
59
- } catch {
60
- return { provider: provider.id, providerName: provider.name, models: [] as ModelRow[], failed: true }
61
- }
62
- }))
63
- const rows = listed.flatMap(entry => entry.models)
64
- const failures = listed.filter(entry => 'failed' in entry && entry.failed === true).map(entry => entry.provider)
65
- return { rows, failures }
66
- }
1
+ /**
2
+ * Model directory for the `/model` panel: the in-process equivalent of the
3
+ * web host's model catalog (`buildModelCatalog`), reading the advisory
4
+ * `ctx.llm` registry directly. Catalog membership is advisory — a route
5
+ * serving a model it stopped advertising stays usable — so selection never
6
+ * fails on catalog absence alone.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/models
9
+ */
10
+
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { ModelSelection } from '@deepseek-ai/dsh-agent'
13
+ import {
14
+ ReasoningEffortId,
15
+ type LlmModelInfo,
16
+ type LlmModelReasoningInfo,
17
+ type LlmResolvedModelInfo,
18
+ } from '@deepseek-ai/dsh-llm'
19
+
20
+ /** Display metadata for one adapter-owned reasoning effort (mirrors `LlmReasoningEffortInfo`). */
21
+ export interface ModelReasoningEffort {
22
+ /** Opaque value accepted by the model's `GenerateOptions.reasoningEffort`. */
23
+ id: string
24
+ /** Human-readable effort name for selectors. */
25
+ name: string
26
+ /** Optional user-facing distinction from otherwise similar efforts. */
27
+ description?: string
28
+ }
29
+
30
+ /** Selectable reasoning efforts for one model (mirrors `LlmModelReasoningInfo`). */
31
+ export interface ModelReasoning {
32
+ /** Supported efforts in adapter-preferred display order. */
33
+ efforts: readonly ModelReasoningEffort[]
34
+ /** Adapter-configured default materialized when callers omit an effort. */
35
+ defaultEffort?: string
36
+ }
37
+
38
+ /** One selectable row in the `/model` panel. */
39
+ export interface ModelRow {
40
+ /** Registered provider route. */
41
+ provider: string
42
+ /** Display name of the provider route. */
43
+ providerName: string
44
+ /** Provider-owned model id. */
45
+ model: string
46
+ /** Human-readable model name. */
47
+ modelName: string
48
+ /** Adapter-owned selectable reasoning levels when the model exposes any. */
49
+ reasoning?: ModelReasoning
50
+ }
51
+
52
+ /** The resolved directory: rows plus per-provider discovery failures. */
53
+ export interface ModelDirectory {
54
+ /** Advisory rows, provider-major in registry order. */
55
+ rows: readonly ModelRow[]
56
+ /** Provider ids whose model listing failed; those providers contribute no rows. */
57
+ failures: readonly string[]
58
+ /**
59
+ * `provider/model` labels whose per-model capability lookup failed. Those
60
+ * rows still appear (advisory degrade, mirroring the web catalog), but
61
+ * without an effort picker — a picker caller must not misread the absence
62
+ * as "this model exposes no reasoning" (e.g. deepseek-v4-flash always
63
+ * advertises off/high/max unless thinking is disabled). Optional for
64
+ * callers that shape a directory by hand; {@link loadModelDirectory}
65
+ * always populates it (empty when nothing failed).
66
+ */
67
+ reasoningFailures?: readonly string[]
68
+ }
69
+
70
+ /** Map an adapter's reasoning capability onto the panel's plain-id shape. */
71
+ export function mapReasoning(reasoning: LlmModelReasoningInfo): ModelReasoning {
72
+ return {
73
+ efforts: reasoning.efforts.map(effort => ({
74
+ id: effort.id,
75
+ name: effort.name,
76
+ ...effort.description === undefined ? {} : { description: effort.description },
77
+ })),
78
+ ...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve the effective model selection for one live session, in the
84
+ * documented precedence: the in-process explicit pick, then the session's
85
+ * last `request/header` config, then the deployment default.
86
+ * @param picked - the explicit selection made in this process, when any.
87
+ * @param logged - the last logged request header config, when any.
88
+ * @param defaults - the deployment default selection.
89
+ * @returns the effective selection, carrying a reasoning effort when one is in force.
90
+ */
91
+ export function resolveEffectiveSelection(
92
+ picked: ModelSelection | undefined,
93
+ logged: { provider: string; model: string; reasoningEffort?: string } | undefined,
94
+ defaults: ModelSelection,
95
+ ): ModelSelection {
96
+ if (picked !== undefined) return picked
97
+ if (logged !== undefined) {
98
+ return {
99
+ provider: logged.provider,
100
+ model: logged.model,
101
+ ...logged.reasoningEffort === undefined
102
+ ? {}
103
+ : { reasoningEffort: ReasoningEffortId(logged.reasoningEffort) },
104
+ }
105
+ }
106
+ return defaults
107
+ }
108
+
109
+ /**
110
+ * Build the selection one `/model` pick applies, rejecting an effort the
111
+ * row does not advertise. The row is the picker's source of truth, so a
112
+ * stale directory cannot smuggle an unsupported effort into the next step
113
+ * (the request pipeline would reject it before network I/O regardless). The
114
+ * empty string is the picker's "provider default" sentinel — an explicit
115
+ * choice to leave the effort to the model's own default, exactly like an
116
+ * absent effort.
117
+ * @param row - the picked model row.
118
+ * @param effortId - the chosen advertised effort, '' or undefined for the model default.
119
+ * @returns the selection the runner records for the next assembled step.
120
+ */
121
+ export function buildModelSelection(row: ModelRow, effortId?: string): ModelSelection {
122
+ const selected = effortId === undefined || effortId === '' ? undefined : effortId
123
+ if (selected !== undefined
124
+ && (row.reasoning === undefined || !row.reasoning.efforts.some(effort => effort.id === selected))) {
125
+ throw new Error(`model ${row.provider}/${row.model} does not support reasoning effort "${selected}"`)
126
+ }
127
+ return {
128
+ provider: row.provider,
129
+ model: row.model,
130
+ ...selected === undefined ? {} : { reasoningEffort: ReasoningEffortId(selected) },
131
+ }
132
+ }
133
+
134
+ /** Display label for one applied selection: `provider/model` or `provider/model@effort`. */
135
+ export function modelSelectionLabel(selection: ModelSelection): string {
136
+ return selection.reasoningEffort === undefined
137
+ ? `${selection.provider}/${selection.model}`
138
+ : `${selection.provider}/${selection.model}@${selection.reasoningEffort}`
139
+ }
140
+
141
+ /**
142
+ * Load the selectable model directory from the live `ctx.llm` registry.
143
+ * Providers are listed synchronously; each provider's models are discovered
144
+ * with a bounded parallel fan-out whose failures degrade to that provider
145
+ * contributing no rows (mirrors the web catalog's per-provider failures).
146
+ * Each row's reasoning levels are resolved per exact model like the web
147
+ * catalog (`buildModelCatalog`); a single model's capability lookup failure
148
+ * degrades to that row having no effort picker rather than hiding the model,
149
+ * and the failure rides `reasoningFailures` so the caller can tell "no
150
+ * advertised reasoning" from "capability lookup failed".
151
+ * @param ctx - context carrying the `llm` service.
152
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
153
+ */
154
+ export async function loadModelDirectory(ctx: Context): Promise<ModelDirectory> {
155
+ const llm = ctx.get('llm')
156
+ if (llm === undefined) return { rows: [], failures: [], reasoningFailures: [] }
157
+ // Call resolveModelInfo AS A METHOD (llm.resolveModelInfo(...)): destructured
158
+ // off the service it loses `this` — this.registration() then throws on the
159
+ // first provider, the catch swallows it, and every row lands in
160
+ // reasoningFailures ("capability lookup failed") even though the adapter
161
+ // itself never fails.
162
+ const llmResolve = llm as { resolveModelInfo?: (provider: string, model: string) => Promise<LlmResolvedModelInfo> }
163
+ const providers = llm.listProviders()
164
+ const listed = await Promise.all(providers.map(async (provider) => {
165
+ try {
166
+ const models: readonly LlmModelInfo[] = await llm.listModels(provider.id)
167
+ const reasoningFailures: string[] = []
168
+ const rows = await Promise.all(models.map(async (model): Promise<ModelRow> => {
169
+ const row: ModelRow = {
170
+ provider: provider.id,
171
+ providerName: provider.name,
172
+ model: model.id,
173
+ modelName: model.name,
174
+ }
175
+ if (llmResolve.resolveModelInfo === undefined) return row
176
+ try {
177
+ const resolved = await llmResolve.resolveModelInfo(provider.id, model.id)
178
+ return resolved.reasoning === undefined
179
+ ? row
180
+ : { ...row, reasoning: mapReasoning(resolved.reasoning) }
181
+ } catch {
182
+ reasoningFailures.push(`${provider.id}/${model.id}`)
183
+ return row
184
+ }
185
+ }))
186
+ return {
187
+ provider: provider.id,
188
+ providerName: provider.name,
189
+ models: rows,
190
+ reasoningFailures,
191
+ }
192
+ } catch {
193
+ return { provider: provider.id, providerName: provider.name, models: [] as ModelRow[], failed: true }
194
+ }
195
+ }))
196
+ const rows = listed.flatMap(entry => entry.models)
197
+ const failures = listed.filter(entry => 'failed' in entry && entry.failed === true).map(entry => entry.provider)
198
+ const reasoningFailures = listed.flatMap(entry => 'failed' in entry ? [] : entry.reasoningFailures)
199
+ return { rows, failures, reasoningFailures }
200
+ }
@@ -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,