Skip to content

Commit 487dbaa

Browse files
committed
.
1 parent 6c1df48 commit 487dbaa

4 files changed

Lines changed: 1083 additions & 340 deletions

File tree

src/commands/environment.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
55
import { alsoKnownAs, apiRoutes } from '../lib/help'
66
import { repoFromCwd } from '../lib/git'
77
import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output'
8+
import { effectiveEnvironmentDefault } from '../lib/sessions'
89
import { resolveRepoFlag } from './config'
910
import { readConfigFile } from './session'
1011
import type {
@@ -160,10 +161,9 @@ export function registerEnvironment(program: Command): void {
160161
await runAction(async () => {
161162
const ladder = await api().environments.defaults.list()
162163
const repo = repoFromCwd(process.cwd())
163-
const repoRung = repo ? repoDefault(ladder, repo) : undefined
164-
const effective = repoRung ?? ladder.account ?? null
164+
const effective = effectiveEnvironmentDefault(ladder, repo ?? null)
165165
if (opts.json) {
166-
printJson({ repository: repo ?? null, effective })
166+
printJson({ repository: repo ?? null, effective: effective?.id ?? null })
167167
return
168168
}
169169
if (!effective) {
@@ -174,8 +174,8 @@ export function registerEnvironment(program: Command): void {
174174
)
175175
return
176176
}
177-
const rung = repoRung ? `repo default for ${repo}` : 'account default'
178-
console.log(`using environment "${effective}" (${rung})`)
177+
const rung = effective.rung === 'repo' ? `repo default for ${repo}` : 'account default'
178+
console.log(`using environment "${effective.id}" (${rung})`)
179179
})
180180
})
181181

@@ -243,7 +243,11 @@ export function registerEnvironment(program: Command): void {
243243
return
244244
}
245245
const rung = repository ? `default for ${repository}` : 'account default'
246-
const id = repository ? repoDefault(ladder, repository) : ladder.account
246+
// Echo the id the ladder now holds for the rung we just wrote, so a
247+
// name argument comes back resolved. Only that rung, never the
248+
// fallback below it.
249+
const set = effectiveEnvironmentDefault(ladder, repository ?? null)
250+
const id = repository ? (set?.rung === 'repo' ? set.id : undefined) : ladder.account
247251
console.log(`✓ set ${rung} to ${id ?? environmentId}`)
248252
})
249253
},
@@ -302,13 +306,6 @@ function environmentSource(e: SavedEnvironment): string {
302306
return 'api'
303307
}
304308

305-
function repoDefault(ladder: EnvironmentDefaults, repo: string): string | undefined {
306-
const match = Object.entries(ladder.repositories).find(
307-
([name]) => name.toLowerCase() === repo.toLowerCase(),
308-
)
309-
return match?.[1]
310-
}
311-
312309
const STARTER_ENVIRONMENT = `# Ellipsis environment: the machine your agents run in, defined once for the
313310
# team. Commit to your default branch (synced locations: agents/, .agents/,
314311
# ellipsis/, .ellipsis/), or create it live with \`agent environment create -f\`.

src/lib/sessions.ts

Lines changed: 242 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { theme } from './theme'
55
import type {
66
AgentSession,
77
AgentSessionSource,
8+
EnvironmentDefaults,
89
ListAgentSessionsQuery,
910
ModelManufacturer,
1011
ModelRateCard,
@@ -405,70 +406,268 @@ export function composerPickerRows(
405406
return rows
406407
}
407408

408-
// A saved config's display name (the YAML's ellipsis.name), falling back to
409-
// the row id.
410-
export function configDisplayName(config: {
411-
id: string
412-
agent_config: Record<string, unknown>
413-
}): string {
414-
const ellipsis = config.agent_config?.ellipsis
415-
if (ellipsis && typeof ellipsis === 'object') {
416-
const name = (ellipsis as Record<string, unknown>).name
417-
if (typeof name === 'string' && name.trim()) return name
409+
export const CUSTOM_ENVIRONMENT_DIVIDER = 'custom environment'
410+
export const VARIABLES_HEADING = 'variables'
411+
export const ADD_VARIABLE_LABEL = '+ new variable'
412+
413+
// The built-in "no environment at all" option's id. A sentinel, never sent: the
414+
// launcher translates it into the cleared-list override, since the wire has no
415+
// name for "empty" (omitting the environment would let the ladder resolve one).
416+
export const EMPTY_ENVIRONMENT_ID = 'empty:builtin'
417+
export const EMPTY_ENVIRONMENT_LABEL = '[empty]'
418+
419+
// The open Environment list, top to bottom: the saved environments and
420+
// "[empty]" as options, a divider, then the custom section — one checkbox per
421+
// stored secret, then the variables typed here, then the add button.
422+
//
423+
// `hover` is the index ↑/↓ walks; rows without one are DECORATION the highlight
424+
// skips, which is what keeps this a plain list rather than a nested tree.
425+
export type EnvironmentPickerRow =
426+
| { kind: 'option'; at: number; hover: number }
427+
| { kind: 'divider'; label: string }
428+
| { kind: 'heading'; label: string }
429+
| { kind: 'secret'; name: string; hover: number }
430+
| { kind: 'variable'; name: string; hover: number }
431+
| { kind: 'addVariable'; hover: number }
432+
433+
// What activating a hovered row does, without the renderer having to know the
434+
// list's shape.
435+
export type EnvironmentTarget =
436+
| { kind: 'option'; at: number }
437+
| { kind: 'secret'; name: string }
438+
| { kind: 'variable'; name: string }
439+
| { kind: 'addVariable' }
440+
441+
export interface EnvironmentPickerInput {
442+
optionCount: number
443+
// The account's stored secret names (values are write-only, so checking one
444+
// adds a variable with no value and the sandbox resolves it at start).
445+
secretNames: readonly string[]
446+
customVariables: readonly CustomVariable[]
447+
}
448+
449+
export function environmentPickerRows(input: EnvironmentPickerInput): EnvironmentPickerRow[] {
450+
const rows: EnvironmentPickerRow[] = []
451+
let hover = 0
452+
for (let at = 0; at < input.optionCount; at++) rows.push({ kind: 'option', at, hover: hover++ })
453+
rows.push({ kind: 'divider', label: CUSTOM_ENVIRONMENT_DIVIDER })
454+
rows.push({ kind: 'heading', label: VARIABLES_HEADING })
455+
for (const name of input.secretNames) rows.push({ kind: 'secret', name, hover: hover++ })
456+
// A variable typed here whose name is also a secret rides that secret's row
457+
// instead of getting a second one: one name, one row, whichever way it got in.
458+
for (const v of input.customVariables) {
459+
if (input.secretNames.includes(v.name)) continue
460+
rows.push({ kind: 'variable', name: v.name, hover: hover++ })
418461
}
419-
return config.id
462+
rows.push({ kind: 'addVariable', hover: hover++ })
463+
return rows
420464
}
421465

422-
// "owner/name" -> the config-override repository shape.
423-
export function repoOverrideEntry(fullName: string): { owner: string; name: string } | null {
424-
const [owner, name] = fullName.split('/')
425-
if (!owner || !name) return null
426-
return { owner, name }
466+
// Where a hover index lands, clamped to the list.
467+
export function environmentPickerAt(
468+
input: EnvironmentPickerInput,
469+
hover: number,
470+
): EnvironmentTarget {
471+
const rows = environmentPickerRows(input)
472+
const landable = rows.filter(
473+
(r): r is Extract<EnvironmentPickerRow, { hover: number }> => 'hover' in r,
474+
)
475+
const row = landable[Math.min(Math.max(0, hover), landable.length - 1)]
476+
if (row.kind === 'option') return { kind: 'option', at: row.at }
477+
if (row.kind === 'secret') return { kind: 'secret', name: row.name }
478+
if (row.kind === 'variable') return { kind: 'variable', name: row.name }
479+
return { kind: 'addVariable' }
427480
}
428481

429-
// The composer's picks, as the new-session pane reports them. `repos` null =
430-
// the Repository row was never touched, so the server's own resolution stands;
431-
// an array is an explicit checkout set, and [] is the legitimate "no repository
432-
// at all" sandbox.
482+
export function environmentPickerCount(input: EnvironmentPickerInput): number {
483+
return environmentPickerRows(input).filter((r) => 'hover' in r).length
484+
}
485+
486+
// What the resting Environment row says: the picked environment, plus a count
487+
// of whatever the custom section adds on top of it.
488+
export function environmentRowSummary(
489+
label: string,
490+
customVariables: readonly CustomVariable[],
491+
): string {
492+
if (customVariables.length === 0) return label
493+
const n = customVariables.length
494+
return `${label} +${n} variable${n === 1 ? '' : 's'}`
495+
}
496+
497+
// How a variable reads in the custom section: the name alone when the sandbox
498+
// resolves its value from stored secrets, otherwise the value as typed. The
499+
// VARIABLES heading already says what these are, so a valueless row needs no
500+
// note of its own.
501+
export function variableRowLabel(name: string, value: string | null | undefined): string {
502+
return value === undefined || value === null ? name : `${name}=${value}`
503+
}
504+
505+
// A variable the launcher's custom section adds on top of the picked
506+
// environment. A null value means "resolve this name from the account's stored
507+
// secrets", the same reading `agent variable set NAME` and the environment YAML
508+
// give it.
509+
export interface CustomVariable {
510+
name: string
511+
value: string | null
512+
}
513+
514+
// The composer's picks, as the new-session pane reports them. environment and
515+
// model null = that row was never touched, so the server resolves it (the
516+
// environment ladder, the account's default model). `emptyEnvironment` is the
517+
// built-in "[empty]" pick: no saved environment, and the resolved lists cleared.
433518
export interface ComposerChoices {
434-
configId: string | null
519+
environment: string | null
435520
model: string | null
436-
repos: string[] | null
521+
emptyEnvironment: boolean
522+
// The picked environment's own variables, needed because an override array
523+
// REPLACES the resolved list rather than appending to it.
524+
baseVariables: readonly CustomVariable[]
525+
customVariables: readonly CustomVariable[]
526+
}
527+
528+
// One variable list from the two that have to end up in the override, with a
529+
// later name winning: a custom entry that repeats a base name is an edit of it,
530+
// in place, not a duplicate the server would have to break the tie on.
531+
export function mergeVariables(
532+
base: readonly CustomVariable[],
533+
custom: readonly CustomVariable[],
534+
): CustomVariable[] {
535+
const merged: CustomVariable[] = []
536+
const at = new Map<string, number>()
537+
for (const v of [...base, ...custom]) {
538+
const seen = at.get(v.name)
539+
if (seen === undefined) {
540+
at.set(v.name, merged.length)
541+
merged.push(v)
542+
} else merged[seen] = v
543+
}
544+
return merged
545+
}
546+
547+
// A variable in the shape an environment override takes: `value` omitted (not
548+
// null) when the name resolves from stored secrets, since the config schema
549+
// treats an absent value as the secret lookup.
550+
function variableEntry(v: CustomVariable): { name: string; value?: string } {
551+
return v.value === null ? { name: v.name } : { name: v.name, value: v.value }
437552
}
438553

439-
// The entry point's base request with the composer's picks layered on: a saved
440-
// config as the source, the model + repositories as a per-run config override
441-
// (the dashboard composer's shape).
554+
// The entry point's base request with the composer's picks layered on: the
555+
// environment as the session's own choice, the model and any custom
556+
// environment edits as a per-run override (the dashboard composer's shape).
557+
//
558+
// Every list in an override REPLACES the resolved one, so a custom variable
559+
// ships alongside the picked environment's own — that is what makes the custom
560+
// section additive rather than a silent wipe of the environment it sits under.
442561
export function applyComposerChoices(
443562
base: StartAgentSessionRequest,
444563
choices: ComposerChoices,
445564
): StartAgentSessionRequest {
446565
const req: StartAgentSessionRequest = { ...base }
447-
if (choices.configId) req.config_id = choices.configId
566+
// Never combined with a config source: the launcher sends no config_id, so
567+
// the environment is always the session's to name (the server 400s both).
568+
if (choices.environment) req.environment = choices.environment
448569
const override: Record<string, unknown> = {}
449570
if (choices.model) override.claude = { model: choices.model }
450-
if (choices.repos !== null) {
451-
// Lists replace wholesale in a config override, so this set becomes the
452-
// run's entire checkout — including the empty set, which a sandbox
453-
// supports (zero, one, or many repositories are all valid).
454-
override.environment = {
455-
repositories: choices.repos
456-
.map(repoOverrideEntry)
457-
.filter((e): e is { owner: string; name: string } => e !== null),
458-
}
459-
// The server merges the request's `repository` context into the checkout
460-
// unconditionally, even under an explicit config, so leaving it on would
461-
// re-add a repo the user just unchecked. Dropping it also moves default-
462-
// config resolution off that repo's rung, which is the honest reading of
463-
// "not this one".
464-
if (req.repository != null && !choices.repos.includes(req.repository)) {
465-
delete req.repository
466-
}
571+
const environment: Record<string, unknown> = {}
572+
// "[empty]" names no environment, so the ladder would still resolve one:
573+
// clearing the lists is what actually empties the sandbox.
574+
if (choices.emptyEnvironment) {
575+
environment.repositories = []
576+
environment.mcp_servers = []
577+
}
578+
const variables = mergeVariables(
579+
choices.emptyEnvironment ? [] : choices.baseVariables,
580+
choices.customVariables,
581+
)
582+
if (choices.emptyEnvironment || choices.customVariables.length > 0) {
583+
environment.variables = variables.map(variableEntry)
467584
}
585+
if (Object.keys(environment).length > 0) override.environment = environment
468586
if (Object.keys(override).length > 0) req.override = override
469587
return req
470588
}
471589

590+
// "NAME=value" / "NAME" as typed into the custom section's name field, or an
591+
// error to show in place. A bare name resolves from stored secrets (null
592+
// value); an empty value after the equals is a real empty string, which is a
593+
// legitimate thing to set a variable to.
594+
export function parseVariableEntry(input: string): CustomVariable | { error: string } {
595+
const text = input.trim()
596+
if (!text) return { error: 'name a variable' }
597+
const eq = text.indexOf('=')
598+
const name = (eq === -1 ? text : text.slice(0, eq)).trim()
599+
if (!name) return { error: 'name a variable' }
600+
// The sandbox exports these into a shell, so the name has to be a legal
601+
// shell identifier or the export silently does nothing.
602+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
603+
return { error: `"${name}" is not a valid variable name` }
604+
}
605+
return { name, value: eq === -1 ? null : text.slice(eq + 1) }
606+
}
607+
608+
// Which rungs of the defaults ladder point at one environment: the account
609+
// rung, and every repository rung. An environment can hold several at once, so
610+
// this is a list — "account default, default for acme/api".
611+
export function environmentDefaultRungs(
612+
ladder: EnvironmentDefaults | null,
613+
id: string,
614+
): string[] {
615+
if (!ladder) return []
616+
return [
617+
...(ladder.account === id ? ['account default'] : []),
618+
...Object.entries(ladder.repositories)
619+
.filter(([, envId]) => envId === id)
620+
.map(([repo]) => `default for ${repo}`),
621+
]
622+
}
623+
624+
// The Environment row's options: every saved environment, each labelled with
625+
// the default rungs it holds, then the built-in "[empty]". `picked` is the row
626+
// checked while the row is untouched — the environment the ladder resolves for
627+
// the repo you are standing in, so the launcher can SEND what it shows instead
628+
// of leaving the server to resolve something else.
629+
//
630+
// A "Default" row appears only when no rung resolves at all: then there is no
631+
// name to show and the server's own resolution is the honest answer.
632+
export function environmentOptions(
633+
environments: readonly { id: string; name: string }[],
634+
ladder: EnvironmentDefaults | null,
635+
detectedRepo: string | null,
636+
): { options: { id: string | null; label: string }[]; picked: number } {
637+
const resolved = ladder ? effectiveEnvironmentDefault(ladder, detectedRepo)?.id : undefined
638+
const listed = environments.map((e) => {
639+
const rungs = environmentDefaultRungs(ladder, e.id)
640+
return {
641+
id: e.id as string | null,
642+
label: rungs.length > 0 ? `${e.name} (${rungs.join(', ')})` : e.name,
643+
}
644+
})
645+
const empty = { id: EMPTY_ENVIRONMENT_ID as string | null, label: EMPTY_ENVIRONMENT_LABEL }
646+
const at = resolved ? environments.findIndex((e) => e.id === resolved) : -1
647+
if (at !== -1) return { options: [...listed, empty], picked: at }
648+
return {
649+
options: [{ id: null as string | null, label: 'Default' }, ...listed, empty],
650+
picked: 0,
651+
}
652+
}
653+
654+
// The environment a config-less session in `repo` resolves to: the repo rung of
655+
// the defaults ladder, else the account rung, else null (the basic sandbox).
656+
// Repo names compare case-insensitively, the way GitHub treats them.
657+
export function effectiveEnvironmentDefault(
658+
ladder: EnvironmentDefaults,
659+
repo: string | null,
660+
): { id: string; rung: 'repo' | 'account' } | null {
661+
const repoRung = repo
662+
? Object.entries(ladder.repositories).find(
663+
([name]) => name.toLowerCase() === repo.toLowerCase(),
664+
)?.[1]
665+
: undefined
666+
if (repoRung) return { id: repoRung, rung: 'repo' }
667+
if (ladder.account) return { id: ladder.account, rung: 'account' }
668+
return null
669+
}
670+
472671
// ------------------------------- layout ---------------------------------
473672

474673
// Which slice of the session cells renders when the list overflows the

0 commit comments

Comments
 (0)