mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The frame carried `agentPreset` for surfaces that label the session, but nothing consumed it: `noteAgentPreset` ran only in the switching tab's RPC callback, so a second connected client refetched its catalogs while its session row — the header label's source, and the hero chip's no-op input — kept the composition the session had replaced. `SessionManager.handleHostEnvelope` now folds the frame like the other session frames. Re-applying the switching tab's own frame is a no-op: the merge lowers `blank` only and keeps the row's `updatedAt`.
409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
/**
|
||
* CommandService (`ctx.command`): the '/' command source over the
|
||
* session-keyed directory, the client-contribution registry, and the
|
||
* per-session popupSelect controllers. Candidate synthesis merges the host
|
||
* catalog with contributions by availability, then fuzzy query/position
|
||
* filtering; a host/contribution name collision fails loud. Every execute
|
||
* addresses the session's agent by sessionId — sessions are always
|
||
* agent-backed.
|
||
*/
|
||
import { Service } from 'cordis'
|
||
import type { Context } from 'cordis'
|
||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||
import type {
|
||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||
SubmitOutcome,
|
||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
|
||
import type { CommandDescriptor } from './directory.ts'
|
||
import { CommandDirectory } from './directory.ts'
|
||
import { PopupSelectController } from './popup.ts'
|
||
import type { TokenSegment } from './popup.ts'
|
||
|
||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||
interface LiveState {
|
||
readonly contributions: Map<string, CommandContribution>
|
||
readonly decorations: Map<string, CommandDecoration>
|
||
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
|
||
}
|
||
|
||
/** One fuzzy match with its stable source position. */
|
||
interface RankedCandidate {
|
||
readonly candidate: SlashCandidate
|
||
readonly index: number
|
||
readonly prefix: boolean
|
||
readonly score: number
|
||
}
|
||
|
||
/** Extra weight for command-name starts and separator boundaries. */
|
||
function boundaryBonus(name: string, index: number): number {
|
||
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
|
||
}
|
||
|
||
/**
|
||
* Score the strongest ordered-subsequence alignment in O(name × query).
|
||
* Boundary and adjacent matches earn weight; skipped and leading characters
|
||
* cost weight.
|
||
*/
|
||
function fuzzyScore(name: string, query: string): number | undefined {
|
||
if (query === '') return 0
|
||
if (query.length > name.length) return undefined
|
||
const noMatch = Number.NEGATIVE_INFINITY
|
||
let previous = Array<number>(name.length).fill(noMatch)
|
||
for (let index = 0; index < name.length; index++) {
|
||
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
|
||
}
|
||
for (let queryIndex = 1; queryIndex < query.length; queryIndex++) {
|
||
const current = Array<number>(name.length).fill(noMatch)
|
||
let bestGapped = noMatch
|
||
for (let index = 0; index < name.length; index++) {
|
||
const gappedIndex = index - 2
|
||
if (gappedIndex >= 0) {
|
||
const prior = previous[gappedIndex] ?? noMatch
|
||
if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex)
|
||
}
|
||
if (name.charAt(index) !== query.charAt(queryIndex)) continue
|
||
const bonus = 1 + boundaryBonus(name, index)
|
||
const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch
|
||
if (adjacent !== noMatch) current[index] = adjacent + bonus + 4
|
||
if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index)
|
||
}
|
||
previous = current
|
||
}
|
||
let best = noMatch
|
||
for (const score of previous) best = Math.max(best, score)
|
||
return best === noMatch ? undefined : best
|
||
}
|
||
|
||
/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */
|
||
function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] {
|
||
const query = rawQuery.toLowerCase()
|
||
if (query === '') return candidates
|
||
const ranked: RankedCandidate[] = []
|
||
candidates.forEach((candidate, index) => {
|
||
const name = candidate.name.toLowerCase()
|
||
const score = fuzzyScore(name, query)
|
||
if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score })
|
||
})
|
||
ranked.sort((left, right) =>
|
||
Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index)
|
||
return ranked.map(match => match.candidate)
|
||
}
|
||
|
||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||
export class CommandService extends Service implements CommandServiceContract {
|
||
static inject = ['slash', 'sessions', 'connection']
|
||
|
||
private readonly directory: CommandDirectory
|
||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||
|
||
/**
|
||
* @param ctx - owning root context (plugin fiber; the service registers
|
||
* itself as `command` and follows that fiber's lifetime).
|
||
*/
|
||
constructor(ctx: Context) {
|
||
super(ctx, 'command')
|
||
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
|
||
this.directory = new CommandDirectory(async (sessionId) => {
|
||
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
|
||
const { result } = await connection.api.commands.list({ sessionId })
|
||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||
return result.value.commands
|
||
})
|
||
const slash = ctx.get('slash')
|
||
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
|
||
ctx.effect(() => slash.registerSource({
|
||
trigger: '/',
|
||
name: 'command',
|
||
candidates: (session, req) => this.candidates(session, req),
|
||
onPick: pick => this.dispatch(pick),
|
||
matchSpace: (session, token) => this.matchSpace(session, token),
|
||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||
}), 'command: slash source')
|
||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||
// A preset switch changes which commands one session's agent resolves and
|
||
// registers nothing globally, so the registry-wide signal above never
|
||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||
// the menu until the new one lands.
|
||
ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
|
||
ctx.on('connection/reset', () => { this.directory.resetConnected() })
|
||
}
|
||
|
||
/**
|
||
* Register one client command contribution; effect disposer (rides the
|
||
* caller's fiber). Duplicate names throw.
|
||
* @param contribution - the contribution (descriptor + availability + popup spec).
|
||
* @returns the disposer removing the registration.
|
||
*/
|
||
register(contribution: CommandContribution): () => void {
|
||
const dispose = this.ctx.effect(() => {
|
||
const { contributions } = this.live
|
||
if (contributions.has(contribution.name)) {
|
||
throw new Error(`ui-command: duplicate contribution for /${contribution.name}`)
|
||
}
|
||
contributions.set(contribution.name, contribution)
|
||
return () => { contributions.delete(contribution.name) }
|
||
}, 'command.register()')
|
||
return () => { void dispose() }
|
||
}
|
||
|
||
/**
|
||
* Hang a bare-invocation decoration on one host command; effect disposer
|
||
* (rides the caller's fiber). Duplicate names throw.
|
||
* @param decoration - host command name + availability + popup spec.
|
||
* @returns the disposer removing the registration.
|
||
*/
|
||
decorate(decoration: CommandDecoration): () => void {
|
||
const dispose = this.ctx.effect(() => {
|
||
const { decorations } = this.live
|
||
if (decorations.has(decoration.name)) {
|
||
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
|
||
}
|
||
decorations.set(decoration.name, decoration)
|
||
return () => { decorations.delete(decoration.name) }
|
||
}, 'command.decorate()')
|
||
return () => { void dispose() }
|
||
}
|
||
|
||
/**
|
||
* Resolve the per-session popup controller (lazy; dies with the session
|
||
* scope). The controller's consume callback dispatches the scoped
|
||
* consume-token event back to this session; focusComposer reaches the
|
||
* composer through the overlay slot currency.
|
||
* @param actx - session-scope ctx.
|
||
* @returns the resident controller.
|
||
*/
|
||
popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
|
||
const sessions = this.sessions()
|
||
const id = sessions.scopeOf(actx)
|
||
if (id === undefined) throw new Error('command.popupFor requires a session scope')
|
||
const { popups } = this.live
|
||
const existing = popups.get(id)
|
||
if (existing !== undefined) return existing
|
||
const controller = new PopupSelectController<ClientSessionContext>({
|
||
consume: segment => actx.bail(actx, 'slash/input-consume-token', {
|
||
guard: segment.via === 'menu'
|
||
? { kind: 'span', span: segment.span }
|
||
: { kind: 'bare-token', token: segment.token },
|
||
}) === true,
|
||
focusComposer: () => { this.focusHooks.get(id)?.() },
|
||
})
|
||
popups.set(id, controller)
|
||
actx.effect(() => () => {
|
||
controller.dispose()
|
||
popups.delete(id)
|
||
this.focusHooks.delete(id)
|
||
}, 'command: session popup')
|
||
return controller
|
||
}
|
||
|
||
/** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
|
||
private readonly focusHooks = new Map<SessionId, () => void>()
|
||
|
||
/**
|
||
* Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
|
||
* @param id - session id.
|
||
* @param focus - textarea focus callback.
|
||
* @returns the unbind disposer.
|
||
*/
|
||
bindComposerFocus(id: SessionId, focus: () => void): () => void {
|
||
this.focusHooks.set(id, focus)
|
||
return () => {
|
||
if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
|
||
}
|
||
}
|
||
|
||
/** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */
|
||
private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise<readonly SlashCandidate[]> {
|
||
const list = await this.directory.ensureReady(session.sessionId, req.signal)
|
||
const rows: SlashCandidate[] = []
|
||
const seen = new Set<string>()
|
||
for (const c of list) {
|
||
seen.add(c.name)
|
||
rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) })
|
||
}
|
||
for (const contribution of this.live.contributions.values()) {
|
||
if (!contribution.available(session)) continue
|
||
if (seen.has(contribution.name)) {
|
||
throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
|
||
}
|
||
rows.push({ name: contribution.name, description: contribution.description })
|
||
}
|
||
return fuzzyCandidates(
|
||
rows.filter(c => req.position === 'leading' || c.hint === undefined),
|
||
req.query,
|
||
)
|
||
}
|
||
|
||
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
|
||
private dispatch(pick: SlashPick): PickOutcome {
|
||
const name = pick.candidate.name
|
||
const contribution = this.live.contributions.get(name)
|
||
if (contribution !== undefined && contribution.available(pick.session)) {
|
||
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
|
||
return 'handled'
|
||
}
|
||
const desc = this.directory.resolve(pick.session.sessionId, name)
|
||
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
|
||
// A decoration replaces the HOST row's bare invocation with its popup;
|
||
// it decorates only a resolvable host command (checked above), never
|
||
// manufactures one, and never touches the argument claim below.
|
||
const decoration = this.live.decorations.get(name)
|
||
if (decoration !== undefined && decoration.available(pick.session)) {
|
||
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
|
||
return 'handled'
|
||
}
|
||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
||
// Menu-pick execute consumes the trigger span before the detached run
|
||
// (scoped event; the input owns the CAS guard).
|
||
this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
|
||
this.runDetached(desc, pick.session, `/${name}`)
|
||
return 'handled'
|
||
}
|
||
|
||
/** Decision table, space column: hot-key sync check; only host leadingInput claims. */
|
||
private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
|
||
if (!token.startsWith('/')) return undefined
|
||
const name = token.slice(1)
|
||
if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
|
||
const desc = this.directory.resolve(session.sessionId, name)
|
||
if (desc === undefined || desc.input === undefined) return undefined
|
||
return { claim: this.leadingClaim(desc, session) }
|
||
}
|
||
|
||
/**
|
||
* Decision table, enter column. Strong-waits the session's catalog (a
|
||
* warmup failure rejects — never a silent downgrade). Contributions and
|
||
* bare host commands act on the bare token only; leadingInput claims
|
||
* args-tolerant.
|
||
*/
|
||
private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome> {
|
||
const trimmed = line.trim()
|
||
if (!trimmed.startsWith('/')) return undefined
|
||
const ws = trimmed.search(/\s/)
|
||
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
|
||
const bare = ws === -1
|
||
const name = token.slice(1)
|
||
if (name === '') return undefined
|
||
const contribution = this.live.contributions.get(name)
|
||
if (contribution !== undefined && contribution.available(session)) {
|
||
if (!bare) return undefined
|
||
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
|
||
return 'handled'
|
||
}
|
||
await this.directory.ensureReady(session.sessionId, signal)
|
||
const desc = this.directory.resolve(session.sessionId, name)
|
||
if (desc === undefined) return undefined
|
||
// Bare enter on a decorated host command opens its popup; an argued line
|
||
// never consults the decoration (the claim/detached paths below own it).
|
||
if (bare) {
|
||
const decoration = this.live.decorations.get(name)
|
||
if (decoration !== undefined && decoration.available(session)) {
|
||
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
|
||
return 'handled'
|
||
}
|
||
}
|
||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||
if (!bare) return undefined
|
||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||
this.runDetached(desc, session, trimmed)
|
||
return 'handled'
|
||
}
|
||
|
||
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
|
||
private openPopup(
|
||
name: string,
|
||
ui: CommandContribution['ui'],
|
||
session: ClientSessionContext,
|
||
segment: TokenSegment,
|
||
): void {
|
||
const actx = this.scopeFor(session.sessionId)
|
||
if (actx === undefined) return
|
||
this.popupFor(actx).open(name, ui, session, segment)
|
||
}
|
||
|
||
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
||
private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
|
||
const token = `/${desc.name} `
|
||
return {
|
||
token,
|
||
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
|
||
submit: (args, _actx) => this.execute(session, token + args),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The command.execute transaction, addressed to the session's agent — pure
|
||
* admission semantics. An unmatched line reports an error outcome (the
|
||
* composer's immediate admission feedback); an admitted command reports
|
||
* plain success regardless of its handler outcome, because the host
|
||
* executor durably logged the lifecycle (`command/run`/`command/done`) and
|
||
* the outcome renders as a persistent flow node — the composer never
|
||
* echoes it. Transport failures throw.
|
||
*/
|
||
private async execute(
|
||
session: ClientSessionContext,
|
||
line: string,
|
||
): Promise<SubmitOutcome> {
|
||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
|
||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||
return { kind: 'success' }
|
||
}
|
||
|
||
/**
|
||
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
|
||
* NOT surfaced here: the host executor durably logs the command lifecycle
|
||
* (`command/run`/`command/done`), and the mux-broadcast events render as a
|
||
* persistent flow node on every tab. Only a transport/admission failure —
|
||
* which never entered a handler and therefore never logged — falls back to
|
||
* the composer notice as immediate feedback.
|
||
*/
|
||
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
|
||
void this.execute(session, line).then(
|
||
(outcome) => {
|
||
// matched:false maps to an error outcome with no logged lifecycle.
|
||
if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
|
||
},
|
||
(error: unknown) => {
|
||
this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))
|
||
},
|
||
)
|
||
}
|
||
|
||
/** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */
|
||
private consumeVia(id: SessionId, segment: TokenSegment): void {
|
||
const actx = this.scopeFor(id)
|
||
if (actx === undefined) return
|
||
actx.bail(actx, 'slash/input-consume-token', {
|
||
guard: segment.via === 'menu'
|
||
? { kind: 'span', span: segment.span }
|
||
: { kind: 'bare-token', token: segment.token },
|
||
})
|
||
}
|
||
|
||
/** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
|
||
private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
|
||
const actx = this.scopeFor(id)
|
||
if (actx === undefined) return
|
||
const conversation = actx.get('conversation')
|
||
if (conversation === undefined) return
|
||
conversation.input.for(actx).notify(level, text)
|
||
}
|
||
|
||
/** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */
|
||
private scopeFor(id: SessionId): ClientContext | undefined {
|
||
return this.sessions().scope(id)
|
||
}
|
||
|
||
private sessions(): ISessions {
|
||
const sessions = this.ctx.get('sessions')
|
||
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
|
||
return sessions
|
||
}
|
||
}
|