Merge origin/master: web permission sandbox, default pi-ai providers

This commit is contained in:
Turtle
2026-07-29 14:29:32 +08:00
parent 42e3cceb64
commit e7c0a5b794
147 changed files with 6770 additions and 195 deletions

View File

@@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -178,11 +178,11 @@ export class AppCLIEntry {
overrides.set(entryId, bag)
}
// Source 0: computed engineering defaults. The session store defaults to
// a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
// is shared across every cwd, not a project-local ./.sessions. The profile
// Source 0: computed engineering defaults. The session store is the one
// shared root every dsh surface resolves, so history follows the user across
// working directories instead of splitting per project. The profile
// (Source 1) overwrites this same field via last-write-wins in put().
put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
put('session-persistence-jsonl', 'root', resolveSessionsRoot())
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
@@ -209,6 +209,7 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)

View File

@@ -2,9 +2,9 @@
* Commander adapter for the `dsh` command-line entry: the one place argv is
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
* real subcommand. Commander owns `--help`/`--version` and parse errors — it
* prints and exits at the point of failure (a domain failure routes through
* subcommand) is the TUI/headless surface with option-only flags; `meta` and
* `web` are real subcommands. Commander owns `--help`/`--version` and parse
* errors — it prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode.
* @module @deepseek-ai/dsh/args
*/
@@ -24,6 +24,39 @@ interface HeadlessInvocation {
prompt: string
}
/**
* Interactive TUI over this harness checkout: `dsh meta`. Identical to
* {@link TuiInvocation} except the workspace is the launcher's own source tree
* rather than the invoking directory. No `--config`: booting a foreign tree
* against the harness workspace is the `--config` case, not this one.
*/
interface MetaInvocation {
mode: 'meta'
resume?: string
}
/**
* Guided fresh-session entries: `dsh migrate` seeds the first turn with the
* `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a
* fresh session in the invoking directory and takes no options — `--resume`,
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
*/
interface SkillSessionInvocation {
mode: 'migrate' | 'upgrade'
}
/**
* List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface
* that boots no agent tree — it reads the cross-process session registry and
* exits. `json` selects the machine-readable form over the human table. There
* is no workspace filter: the listing is always every live session, whatever
* directory it runs in.
*/
interface ListSessionsInvocation {
mode: 'list-sessions'
json: boolean
}
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed — pass-through overrides with no CLI default and no CLI validation:
@@ -45,7 +78,13 @@ interface WebInvocation {
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
export type DshInvocation =
| TuiInvocation
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
| ListSessionsInvocation
| WebInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
@@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: interactive TUI (default), headless task, and browser UI')
.description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
// The default surface takes no positional task, so `dsh "task"` fails
// commander's arity check with no hint; these examples are where a first
// reader learns the entry points and that a one-shot task rides `-p`.
.addHelpText('after', `
Examples:
dsh start an interactive session in this directory
dsh -p "run the tests" answer one task, print the result, and exit
dsh --resume <id> continue a past session (list ids with \`dsh ps\`)
`)
.exitOverride()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
.option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
.option('--resume <id>', 'continue a past session by id (list ids with `dsh ps`)')
.option('--config <path>', 'start with an alternate plugin configuration file')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
@@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
}
})
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
// Commander parses the parent (default-surface) options on either side of a
// subcommand into `program.opts()`. For a subcommand that shares none of them,
// a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`)
}
}
// Registration order is the rendered help order, so daily use comes first
// and the harness-development surfaces (`web --dev`, `meta`) come last.
// `migrate` and `upgrade` are guided fresh-session entries: they take no
// options and always mint a fresh session, so nothing is left to carry. Each
// description names the outcome, not the skill the first turn invokes.
const guided = {
migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)',
upgrade: 'update this dsh installation to the latest version',
} as const
for (const mode of ['migrate', 'upgrade'] as const) {
program
.command(mode)
.description(guided[mode])
.action(() => {
rejectParentOptions(mode)
resolved = { mode }
})
}
program
.command('list-sessions')
.alias('ps')
.description('list sessions running right now')
.option('--json', 'print the records as a JSON array instead of a table')
.action((options: { json?: boolean }) => {
rejectParentOptions('list-sessions')
resolved = { mode: 'list-sessions', json: options.json === true }
})
// Host and port name no default: the CLI passes neither through when the flag
// is absent, so the shipped `cordis.yml` value stands and restating it here
// would duplicate a fact this file does not own.
const web = program.command('web').description('serve the browser UI on the configured host and port')
web
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
// fail loud rather than silently start the web server and drop it.
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
program.error('error: web takes none of --config, -p/--prompt, or --resume')
}
rejectParentOptions('web')
resolved = resolveWeb(options)
})
// `--resume` is NOT redeclared here: an option a subcommand shares with its
// parent parses into `program.opts()` and leaves the subcommand's own options
// empty, so redeclaring it would silently drop the id. Commander therefore
// omits it from this subcommand's option list, hence the trailing help text.
program
.command('meta')
.description('work on the dsh source that runs this command, from any directory')
.addHelpText('after', '\nAccepts --resume <id> to resume a persisted session from this checkout.\n')
.action(() => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `meta` accepts only `--resume`, so
// a leaked `--config`/`-p` is a mistyped invocation that must fail loud
// rather than silently be dropped.
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined) {
program.error('error: meta takes neither --config nor -p/--prompt')
}
// Same reason as the default surface: an empty id would start a fresh
// session downstream instead of failing the mistyped resume.
if (parent.resume === '') program.error('error: --resume needs a session id')
resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } }
})
try {
program.parse(argv, { from: 'user' })
} catch (error) {

View File

@@ -43,6 +43,22 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume)
break
}
case 'meta': {
const { runMeta } = await import('./tui.ts')
await runMeta(invocation.resume)
break
}
case 'list-sessions': {
const { runListSessions } = await import('./list-sessions.ts')
await runListSessions(invocation.json)
break
}
case 'migrate':
case 'upgrade': {
const { runSkillSession } = await import('./tui.ts')
await runSkillSession(`dsh-${invocation.mode}`)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)

View File

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
import { registerLiveSessions } from './register-session.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise<void> {
port: 0,
})
const { ctx, port } = await entry.run()
await registerLiveSessions(ctx)
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)

View File

@@ -0,0 +1,101 @@
/**
* `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now.
*
* A read-only surface: it mounts the session registry alone and never boots an
* agent tree, so listing stays fast and cannot start model work as a side
* effect. Liveness comes from the registry, which prunes records whose process
* is gone, and every displayed field including the title comes from the record,
* so no session log is opened and no backend format is assumed.
* @module @deepseek-ai/dsh/list-sessions
*/
import { Context } from 'cordis'
import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
import { registryRoot } from './register-session.ts'
/** Column header text, also the minimum width of each column. */
const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const
/** Shown when a session has no title yet. */
const NO_TITLE = '—'
/**
* Render milliseconds of uptime as a compact human duration.
* @param ms - elapsed milliseconds since the session registered.
* @returns a short duration such as `12s`, `4m`, or `2h14m`.
*/
export function formatUptime(ms: number): string {
const seconds = Math.max(0, Math.floor(ms / 1000))
if (seconds < 60) return `${String(seconds)}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${String(minutes)}m`
const hours = Math.floor(minutes / 60)
const remainder = minutes % 60
if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m`
const days = Math.floor(hours / 24)
const leftoverHours = hours % 24
return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h`
}
/** One fully-resolved listing row, in column order. */
type Row = readonly [string, string, string, string, string]
/**
* Build the display rows for a listing, newest session first.
* @param records - the live records to render.
* @param now - the current epoch milliseconds uptime is measured against.
* @returns one row per record, each already stringified per column.
*/
export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] {
return [...records]
.sort((left, right) => right.startedAt - left.startedAt)
.map(record => [
record.sessionId,
String(record.pid),
formatUptime(now - record.startedAt),
record.cwd,
record.title ?? NO_TITLE,
] as const)
}
/**
* Render rows as a left-aligned table with a header line.
*
* The last column is never padded, so a long title cannot add trailing
* whitespace to every line.
* @param rows - the rows to render, already stringified.
* @returns the complete table text, newline-terminated.
*/
export function renderTable(rows: readonly Row[]): string {
const widths = HEADERS.map((header, column) =>
Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0)))
const line = (cells: readonly string[]): string =>
cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd()
return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n'
}
/**
* List live sessions and exit. Prints a table by default, or a JSON array with
* `--json`; an empty listing is a success, not an error.
* @param json - emit the machine-readable JSON array instead of the table.
*/
export async function runListSessions(json: boolean): Promise<void> {
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root: registryRoot() })
const records = await ctx.sessionRegistry.list()
await ctx.fiber.dispose()
if (json) {
const rows = [...records]
.sort((left, right) => right.startedAt - left.startedAt)
.map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null }))
process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`)
return
}
if (records.length === 0) {
process.stdout.write('no dsh sessions running\n')
return
}
process.stdout.write(renderTable(buildRows(records, Date.now())))
}

View File

@@ -0,0 +1,44 @@
/**
* Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the
* publisher that keeps it in step with this process's sessions.
*
* Both plugins mount on the booted app's own context, so records share that
* fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a
* killed process leaves records the next reader prunes by pid. Only top-level
* surfaces a user launches mount this — in-process subagents have no process of
* their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent`
* rather than this CLI, so neither reaches this path.
* @module @deepseek-ai/dsh/register-session
*/
import { join } from 'node:path'
import type { Context } from 'cordis'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live'
/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */
export const registryRoot = (): string => join(resolveDshHome(), 'run')
/**
* Publish this process's sessions for the lifetime of `ctx`.
*
* Publication follows session lifecycle rather than a launcher-known id, so one
* path serves every surface identically — the TUI's single session and a
* server's on-demand ones alike — and titles reach the listing as they are
* logged.
*
* Mounting is best-effort: a registry failure must not take down a working agent
* session, because the registry is an observability aid rather than part of the
* agent's contract. Failures warn through the context logger.
* @param ctx - the booted app context whose lifetime the records share.
*/
export async function registerLiveSessions(ctx: Context): Promise<void> {
try {
const scope = ctx.isolate('sessionRegistry')
await scope.plugin(SessionRegistryFile, { root: registryRoot() })
await scope.plugin(sessionRegistryLive)
} catch (error) {
ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error))
}
}

View File

@@ -4,13 +4,20 @@
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
* directory: sessions, relative paths, and workspace instructions resolve from
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
* agent's system prompt is told the path to this harness checkout so it can find
* its own source.
* directory: the session cwd, relative paths, and workspace instructions resolve
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta`
* ({@link runMeta}) is the one exception — it makes this harness checkout the
* workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh
* sessions whose first turn auto-invokes a bundled skill. After boot, the
* agent's system prompt is told the path to this harness checkout so it can
* find its own source.
* @module @deepseek-ai/dsh/tui
*/
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -19,13 +26,18 @@ import {
installFailLoud,
loadEnv,
loadPersonalPatches,
RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
import { registerLiveSessions } from './register-session.ts'
import {
INITIAL_SKILL_KEY,
MAIN_SESSION_ID_KEY,
SESSIONS_ROOT_KEY,
TUI_GOODBYE_MESSAGE_KEY,
type MainSessionIdentity,
type TuiResumeHost,
} from '@deepseek-ai/dsh-tui'
@@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/**
* The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its
* shared session-store root, `sessions` under the Harness home. Shared-store
* policy is the launcher's alone — the app bundle treats the slot as opaque and
* keeps a project-local fallback, so only `dsh` decides that sessions are
* shared across working directories (making `/resume` and `list-sessions` span
* every workspace).
* @returns the absolute session-store root this launcher shares.
*/
export function launcherSessionsRoot(): string {
return resolveSessionsRoot()
}
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the tui-agent PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI with this harness checkout as the workspace
* (`dsh meta`), whatever directory it was launched from.
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
* see {@link runTui}. Meta-mode sessions live under the checkout, so an id from
* an ordinary `dsh` run in another directory is not found here.
*/
export async function runMeta(resumeSessionId: string | undefined): Promise<void> {
return runTui(undefined, resumeSessionId, SOURCE_ROOT)
}
/**
* Run the interactive TUI as a guided fresh session whose first turn invokes a
* bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`).
* Always mints a fresh session in the invoking directory; the skill is seeded
* only on this first launch, so a later `--resume` of the session is an ordinary
* TUI session with no re-injection.
* @param skill - the bundled skill name to auto-invoke as the first turn.
*/
export async function runSkillSession(skill: string): Promise<void> {
return runTui(undefined, undefined, undefined, skill)
}
/**
* Run the interactive TUI from the invoking directory.
* @param config - a config path to boot instead of the shipped default, or
* `undefined` for the default; already parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
* already parsed and non-empty-validated from `--resume`. It is provided on the
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
* reads through `!!js` to rehydrate that session.
* @param resumeSessionId - a persisted session id to resume, or `undefined` to
* mint a fresh one; already parsed and non-empty-validated from `--resume`.
* Either way the resulting identity reaches the booted app through
* {@link MAIN_SESSION_ID_KEY}, so no config key selects the session.
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
* on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
*/
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
export async function runTui(
config: string | undefined,
resumeSessionId: string | undefined,
workspace?: string,
initialSkill?: string,
): Promise<void> {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
@@ -66,28 +125,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// Both .env layers are loaded, so switching the workspace here cannot alter
// environment precedence. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// both together. Sessions themselves live under the Harness home so `/resume`
// spans every workspace, and are unaffected by this chdir.
if (workspace !== undefined) process.chdir(workspace)
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The host
// is offered only when Node exposes `process.execve` and knows its own entry.
// flag, so the resumed process rehydrates through this same intake. The
// selected session may belong to another workspace, so the handoff also enters
// that directory. The host is offered only when Node exposes `process.execve`
// and knows its own entry.
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
const resumeCommand = (sessionId: string): string =>
`${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
// Resuming reproduces THIS invocation with a different id. Meta mode is a
// subcommand that rejects `--config`, while the default surface carries it, so
// both the in-place handoff and the printed command derive from one shape.
// `meta` is only reproducible for a target inside this checkout: it chdirs to
// SOURCE_ROOT itself, which would override any other workspace, so a
// cross-workspace resume takes the default surface and the caller supplies the
// directory instead.
const resumeArgs = (sessionId: string, targetCwd?: string): string[] =>
workspace !== undefined && (targetCwd === undefined || targetCwd === workspace)
? ['meta', `--resume=${sessionId}`]
: [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []]
// Mint the fresh id here rather than in the app bundle: the exit line names
// the session to resume, so the launcher must know it before the tree boots.
const identity: MainSessionIdentity = resumeSessionId === undefined
? { id: SessionId(`main-session-${randomUUID()}`), resume: false }
: { id: SessionId(resumeSessionId), resume: true }
const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}`
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
async handoff(sessionId, cwd): Promise<never> {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
// Rebuild argv from the parsed config plus the selected id: TUI mode's
// only arguments are `--config <path>` and `--resume <id>`.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
`--resume=${sessionId}`,
...config !== undefined ? ['--config', config] : [],
...resumeArgs(sessionId, cwd),
]
// `execve` inherits the cwd, and the target session may belong to another
// workspace. Enter it BEFORE teardown commits: an unreachable directory
// (deleted, unreadable) must reject while the caller can still restore the
// terminal, and a chdir after disposal would have no owner to report to.
try {
process.chdir(cwd)
} catch (error) {
@@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
(hostCtx) => {
// Inject the resume id (or undefined) so the shipped config's `!!js`
// reads it as a bare identifier; then offer the in-place handoff host.
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
if (resumeSessionId !== undefined) {
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
}
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
// resume.
hostCtx.provide(MAIN_SESSION_ID_KEY, identity)
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye)
// Shared-store policy is the launcher's: sessions live in one root under
// the Harness home across every cwd, so /resume and list-sessions see
// every workspace. The bundle treats the slot as opaque.
hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot())
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
// Seed the first turn only for a fresh session, so resuming never
// re-invokes the skill.
if (initialSkill !== undefined && resumeSessionId === undefined) {
hostCtx.provide(INITIAL_SKILL_KEY, initialSkill)
}
},
)
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
// Publication follows the store; meta mode already chdir'd, so each session
// reports its own cwd.
await registerLiveSessions(ctx)
}
/* v8 ignore stop */

View File

@@ -8,6 +8,7 @@
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
import { registerLiveSessions } from './register-session.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
@@ -40,6 +41,7 @@ export async function runWeb(
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
await registerLiveSessions(ctx)
let exiting = false
const shutdown = (code: number): void => {