perf(app): row-grained replica cache persistence (#3755)

* feat(hub): guide starter setup (#3651)

* feat(hub): guide setup with provider IDs

* fix(hub): preserve login setup state

* feat(hub): select starter agent runtime

Use the connected daemon's provider snapshot so generated Hub bundles only
contain complete provider, model, and mode combinations.

* fix(hub): stage starter runtime choices

Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime.

* fix(hub): scope runtime choices to workspace

Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy.

* fix(server): stop short start gates from undercutting provider startup budgets (#3621)

* fix(server): stop short start gates from undercutting provider startup budgets

Agent creation still failed on contended cold starts after #3578, because two
shorter gates on the same path fire before the event-stream readiness budget can
apply. A traced creation reached session.create twelve seconds in, past the 10s
session.create gate, and the agent was then killed by a bare "timeout" at fifteen
seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running.

A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the
outer gate always wins and the inner budget is unreachable.

Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since
creating the first session for a directory is part of OpenCode coming up, and
derive its error message from the constant instead of hardcoding "10s". Raise
AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay
above the slowest provider startup budget; it stays a literal because that module
is provider-agnostic.

Related to #3571.

* test(server): drive the run start budget through a real AgentManager

The run start boundary tests replaced AgentManager.waitForAgentRunStart with a
bare timer, so they never exercised the production run-state transition or the
agent_state subscription the real wait depends on. That path could regress while
the tests stayed green.

Build the scenario on a real AgentManager with a provider session that holds its
turn start open for a configurable span, so pendingRun.started, the lifecycle
change to "running", and the agent_state notification are what the wait actually
observes. Budgets stay independent literals and the settled-before/after
assertions are unchanged.

* fix(server): describe agent run start timeouts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(relay): validate handshake keys (#3669)

* fix(files): keep large source previews responsive (#3665)

Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.

* fix(hub): make guided setup choices actionable (#3677)

Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed.

* Keep released agent SDK versions reproducible (#3678)

Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile.

* feat(app): refine agent profile creation (#3680)

Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers.

* fix(app): restore native composer auto-growth (#3681)

Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it.

* docs(hub): document zero-friction onboarding (#3657)

Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults.

* Surface workspace changes from agent panes (#3682)

* feat(app): refine workspace pane actions

Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant.

* feat(app): improve access to workspace changes

* Expand workspace panel actions and keyboard navigation (#3685)

* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.

* Restore compact and stable chat timelines (#3716)

Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position.

* perf(app): defer replica persistence until user inactivity

Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.

* Give every pane a browser-style New tab (#3715)

* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership

* fix(app): keep compact model options scrollable

The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms.

* fix(app): order new tab launcher actions by workflow

* fix(app): show the New agent shortcut

The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping.

* fix(app): prioritize side panel launcher actions

Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation.

* fix(app): align terminal profiles header

* docs: add 0.5.0-beta.5 changelog

* test(app): align new tab launcher expectations

* chore(release): cut 0.5.0-beta.5

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): make compact git actions menu-only

Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu.

* fix(app): keep pinned project actions reachable (#3722)

* fix(app): keep pinned project actions reachable

Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available.

Co-authored-by: Agent Op <agentop718@gmail.com>

* test(app): verify pinned project workspace context

---------

Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(app): stop a string SVG transform from crashing the app (#3711)

* fix(app): stop a string SVG transform from crashing the app

A root <Svg> is a real React Native view, so react-native-svg rewrites a
*string* `transform` into RN style syntax in `extractTransformSvgView`,
using a PEG parser whose grammar only accepts SVG transform functions.
Upstream lets that parser's SyntaxError escape, so any string it does not
understand takes the whole app down during render:

  SyntaxError: Expected transform functions but "n" found.
  "found": "n", "location": { "start": { "offset": 0 } }

`none` reproduces that message character for character; so does any other
CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling
extractor in that file already catches the same error and degrades, so
the asymmetry is the bug: a string that is merely ignored on a <Path>
crashes on an <Svg>.

We feed that path untrusted input. The daemon scans a project for
favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts)
and ships it to the client, where ProjectIconImage hands it to SvgCss.
Running the library's own XML parser over `<svg style="transform:none">`
yields exactly `transform: "none"` on the root, which is why the crash
starts once a host connects and the project icons render, with no
terminal involved.

Note this is *not* reachable from an RN-style array transform: the
extractor returns arrays untouched and only parses strings, so
`style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter
and the checks ring) was never a factor.

Patch the three builds a consumer can resolve - Metro takes the package's
`react-native` field (src/*.ts), Node and Vitest take main/module - and
register react-native-svg with the postinstall patch runner so the patch
actually applies. The new test pins all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ

* test(app): cover parsed root SVG transforms

Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>

* fix(pairing): warn that pairing links grant daemon access (#3734)

A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed.

* fix(app): preserve timeline retry backoff (#3736)

Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action.

* Open the tab chooser beside the pointer (#3735)

* feat(app): open the tab chooser beside the pointer

Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift.

* test(app): follow the anchored tab chooser

The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.

* fix(app): restore composer height lifecycle (#3740)

Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms.

* fix(server): stop quota fetcher from writing provider credentials (#3442)

* fix(server): recover already-settled interrupts (#3742)

* fix(server): recover already-settled interrupts

Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection.

* fix(codex): fence interruption during turn startup

Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work.

* docs(changelog): prepare 0.5.0 stable release

* chore(release): cut 0.5.0

* fix: update lockfile signatures and Nix hash [skip ci]

* fix(app): stabilize native composer resizing

JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.

* docs(changelog): prepare 0.5.1 stable release

* chore(release): cut 0.5.1

* fix: update lockfile signatures and Nix hash [skip ci]

* perf(app): move replica cache validation to restore

Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary.

* feat(app): add replica cache row store

Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets.

* feat(app): switch replica cache to row storage

Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction.

* fix(app): harden replica row persistence

Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained.

---------

Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com>
Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com>
Co-authored-by: Yuri Gui <yugui923@users.noreply.github.com>
Co-authored-by: Agent Op <agentop718@gmail.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dan Berindei <dan.berindei@gmail.com>
This commit is contained in:
Mohamed Boudra
2026-08-24 20:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 5 BrianAguilarWasco paseo-ai[bot] Yuri Gui Agent Op Omer Cohen Dan Berindei
parent 43d27328e4
commit b12daabe35
232 changed files with 10258 additions and 3287 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.5.0-beta.4",
"version": "0.5.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0-beta.4",
"@getpaseo/protocol": "0.5.0-beta.4",
"@getpaseo/server": "0.5.0-beta.4",
"@getpaseo/client": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/server": "0.5.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -120,6 +120,52 @@ describe("Hub HTTP client", () => {
assert.equal(requests[0]?.url, "/api/v1/configuration-resources");
});
it("reads strict provider-native setup resources", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
() => ({
status: 200,
body: {
github: [
{
slug: "getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/paseo"],
},
],
discord: [{ guildId: "guild-123", guildName: "Paseo" }],
slack: [{ teamId: "team-123", teamName: "Paseo" }],
},
}),
requests,
);
const resources = await new HubHttpClient().listSetupResources(origin, "secret");
assert.equal(resources.slack[0]?.teamId, "team-123");
assert.equal(resources.discord[0]?.guildId, "guild-123");
assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" });
});
it.each([
{ github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] },
{ github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] },
])("rejects malformed or unknown setup resource fields", async (body) => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
() => ({
status: 200,
body,
}),
requests,
);
await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), {
code: "HUB_INVALID_RESPONSE",
});
});
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
const requests: Array<{ url: string | undefined; body: string }> = [];
const origin = await startServer(
@@ -87,10 +87,68 @@ describe("Hub commands", () => {
assert.deepEqual(events, [
"progress:Logging in to https://hub.paseo.sh",
"authorize:https://hub.paseo.sh",
"progress:Logged in",
]);
assert.equal(result.data.origin, "https://hub.paseo.sh");
});
it("interactive login continues through the injected guided setup coordinator without invoking a CLI command", async () => {
const credentials = new MemoryCredentials();
const events: string[] = [];
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: {
authorize: async () => {
events.push("login");
return "paseo_cli_prefix_durable-secret";
},
},
isInteractive: () => true,
continueGuidedSetup: async (origin) => {
events.push(`connect:${origin}`);
events.push("init");
events.push("deploy");
},
reporter: { progress: (message) => events.push(`progress:${message}`) },
},
);
assert.deepEqual(events, [
"progress:Logging in to https://hub.test",
"login",
"progress:Logged in",
"connect:https://hub.test",
"init",
"deploy",
]);
});
it("JSON and noninteractive login remain login-only", async () => {
for (const [options, interactive] of [
[{ json: true }, true],
[{}, false],
] as const) {
const credentials = new MemoryCredentials();
let continuationCount = 0;
await runHubLogin(undefined, options, {
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => interactive,
continueGuidedSetup: async () => {
continuationCount += 1;
},
reporter: quietReporter,
});
assert.equal(continuationCount, 0);
}
});
it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => {
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" });
@@ -484,6 +542,10 @@ class FakeDaemon implements HubDaemonClient {
return { status: hubStatus("connected", this.origin) };
}
async getProvidersSnapshot() {
return { entries: [] };
}
async disconnectHub(force: boolean) {
this.disconnects += 1;
this.disconnectForces.push(force);
@@ -1,4 +1,5 @@
import { connectToDaemon } from "../../utils/client.js";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
export interface HubStatus {
state: string;
@@ -9,10 +10,17 @@ export interface HubStatus {
lastError: string | null;
}
export interface HubProvidersSnapshotOptions {
cwd?: string;
}
export interface HubDaemonClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
getHubStatus(): Promise<{ status: HubStatus }>;
disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>;
getProvidersSnapshot(
options?: HubProvidersSnapshotOptions,
): Promise<{ entries: ProviderSnapshotEntry[] }>;
close(): Promise<void>;
}
@@ -12,6 +12,8 @@ import {
type CliAuthorizationPoll,
type HubInstallResult,
type HubConfigurationResources,
setupResourcesSchema,
type HubSetupResources,
type HubProject,
type HubValidationResult,
} from "./internal/contracts.js";
@@ -22,6 +24,7 @@ export type {
CliAuthorizationPoll,
HubInstallResult,
HubConfigurationResources,
HubSetupResources,
HubProject,
HubValidationResult,
} from "./internal/contracts.js";
@@ -95,6 +98,18 @@ export class HubHttpClient {
});
}
listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources> {
return requestHub({
origin,
path: "/api/v1/setup-resources",
method: "GET",
apiKey,
successStatus: 200,
schema: setupResourcesSchema,
failureMessage: "Hub guided setup resource listing failed",
});
}
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
return requestHub({
origin: input.origin,
@@ -54,6 +54,25 @@ export const configurationResourcesSchema = z
})
.strict();
export const setupResourcesSchema = z
.object({
github: z.array(
z
.object({
slug: z.string().min(1),
accountLogin: z.string().min(1),
accountType: z.string().min(1),
repositories: z.array(z.string().min(1)),
})
.strict(),
),
discord: z.array(
z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(),
),
slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()),
})
.strict();
export const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
@@ -75,5 +94,6 @@ export type CliAuthorization = z.infer<typeof authorizationSchema>;
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
export type HubProject = z.infer<typeof projectSchema>;
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
export type HubSetupResources = z.infer<typeof setupResourcesSchema>;
export type HubInstallResult = z.infer<typeof installResponseSchema>;
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
+3 -1
View File
@@ -18,7 +18,7 @@ import { addHubProjectsCommand } from "./projects.js";
import { processHubReporter, type HubReporter } from "./reporter.js";
import { hubStatusResult } from "./status-output.js";
import { addHubResolutionHelp } from "./help.js";
import { addHubInitCommand } from "./init.js";
import { addHubInitCommand, continueHubGuidedSetup } from "./init.js";
interface HubCommandEnvironment {
env: Readonly<Record<string, string | undefined>>;
@@ -56,6 +56,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
isInteractive: environment.isInteractive,
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
});
addHubInitCommand(hub, environment);
addHubConnectCommand(hub, {
@@ -0,0 +1,445 @@
import { strict as assert } from "node:assert";
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { HubCredentialStore, StoredHubCredential } from "./credentials.js";
import type { HubDaemonClient, HubStatus } from "./daemon-client.js";
import type { HubHttpClient } from "./hub-client/index.js";
import {
continueHubGuidedSetup,
runHubGuidedSetup,
type HubGuidedSetupEnvironment,
} from "./init.js";
import { runHubLogin } from "./login.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("Hub guided setup continuation", () => {
it("uses the login origin and connected daemon to validate, write, and deploy without replaying setup prompts", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls);
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => true,
continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment),
reporter: { progress() {} },
},
);
assert.deepEqual(prompts.confirmations, [
"Connect this daemon to this Hub?",
"Initialize and deploy a starter workflow?",
]);
assert.deepEqual(prompts.selections, [
"Starter agent provider",
"Starter agent model",
"Starter agent mode",
]);
assert.deepEqual(prompts.selectionOptions, [
["Codex"],
["GPT-5 (suggested)", "GPT-5 mini"],
["Read only", "Full access (suggested)"],
]);
assert.deepEqual(prompts.messages, [
"Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.",
"Using Slack — Paseo",
]);
assert.deepEqual(calls, [
{ operation: "token", origin: "https://hub.test" },
{ operation: "projects", origin: "https://hub.test" },
{ operation: "setup", origin: "https://hub.test" },
{ operation: "resources", origin: "https://hub.test" },
{
operation: "validate",
origin: "https://hub.test",
files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"],
},
{
operation: "install",
origin: "https://hub.test",
files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"],
},
]);
assert.equal(daemon.connections, 1);
assert.deepEqual(daemon.snapshotCwds, [cwd]);
const hub = await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8");
assert.match(hub, /daemon: macbook/u);
assert.match(hub, /provider: codex\n model: gpt-5\n mode: full-access/u);
});
it("prints exact actionable resume commands for login continuation declines", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
const connectDeclined = new PromptAnswers([false], [], []);
await continueHubGuidedSetup(
"https://hub.test",
setupEnvironment(cwd, credentials, daemon, connectDeclined, []),
);
assert.deepEqual(connectDeclined.messages, [
"Skipped daemon connection. Run: paseo hub connect https://hub.test; then paseo hub init",
]);
const initDeclined = new PromptAnswers([true, false], [], []);
await continueHubGuidedSetup(
"https://hub.test",
setupEnvironment(cwd, credentials, daemon, initDeclined, []),
);
assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]);
});
it("keeps login successful when no Hub app connection can initialize a workflow", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([true, true], [], []);
await runHubLogin(
"https://hub.test",
{},
{
env: {},
credentials,
flow: { authorize: async () => "paseo_cli_prefix_durable-secret" },
isInteractive: () => true,
continueGuidedSetup: (origin) =>
continueHubGuidedSetup(
origin,
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
),
reporter: { progress() {} },
},
);
assert.deepEqual(prompts.messages, [
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
]);
});
it("keeps hub init's existing replacement confirmation", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
const prompts = new PromptAnswers([false], [], []);
await assert.rejects(
runHubGuidedSetup(
setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []),
),
/Existing .paseo\/ bundle left unchanged/u,
);
assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]);
});
it("writes the explicitly selected Claude mode when the daemon has no default", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon([
{
provider: "claude",
status: "ready",
enabled: true,
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
modes: [{ id: "auto", label: "Auto" }],
defaultModeId: null,
},
]);
const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto"], ["U123"]);
const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = [];
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), {
origin: "https://hub.test",
daemonId: "daemon-1",
deploy: true,
});
assert.deepEqual(prompts.selectionOptions.slice(0, 3), [
["claude"],
["Sonnet (suggested)"],
["Auto"],
]);
assert.match(
await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"),
/provider: claude\n model: sonnet\n mode: auto/u,
);
assert.deepEqual(
calls.slice(-2).map(({ operation }) => operation),
["validate", "install"],
);
});
it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const daemon = new SetupDaemon();
const prompts = new PromptAnswers([], [], []);
await assert.rejects(
runHubGuidedSetup(
setupEnvironment(cwd, credentials, daemon, prompts, [], {
setupResources: { github: [], slack: [], discord: [] },
}),
{ origin: "https://hub.test", daemonId: "daemon-1", deploy: true },
),
/No Hub app connection is ready for this workflow/u,
);
assert.equal(daemon.snapshotCwds.length, 0);
await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" });
});
it("waits for fresh-daemon provider discovery before offering runtime choices", async () => {
const cwd = await temporaryDirectory();
const credentials = new MemoryCredentials();
credentials.save({ origin: "https://hub.test", credential: "secret" });
const ready = new SetupDaemon().providerEntries;
const daemon = new SetupDaemon(ready, [
[{ provider: "codex", status: "loading", enabled: true }],
ready,
]);
const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]);
await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), {
origin: "https://hub.test",
daemonId: "daemon-1",
deploy: true,
});
assert.equal(daemon.snapshotCwds.length, 2);
assert.deepEqual(prompts.selections.slice(0, 3), [
"Starter agent provider",
"Starter agent model",
"Starter agent mode",
]);
});
});
function setupEnvironment(
cwd: string,
credentials: HubCredentialStore,
daemon: SetupDaemon,
prompts: PromptAnswers,
calls: Array<{ operation: string; origin: string; files?: readonly string[] }>,
options: {
setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>;
} = {},
): HubGuidedSetupEnvironment {
const hub = {
async issueEnrollmentToken(origin: string) {
calls.push({ operation: "token", origin });
return "one-time-enrollment-token-with-enough-length";
},
async listProjects(origin: string) {
calls.push({ operation: "projects", origin });
return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }];
},
async listSetupResources(origin: string) {
calls.push({ operation: "setup", origin });
return (
options.setupResources ?? {
github: [],
discord: [],
slack: [{ teamId: "T123", teamName: "Paseo" }],
}
);
},
async listConfigurationResources(origin: string) {
calls.push({ operation: "resources", origin });
return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] };
},
async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) {
calls.push({
operation: "validate",
origin: input.origin,
files: input.files.map(({ path: filePath }) => filePath),
});
return { projectSlug: "studio", valid: true };
},
async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) {
calls.push({
operation: "install",
origin: input.origin,
files: input.files.map(({ path: filePath }) => filePath),
});
return {
projectSlug: "studio",
version: 1,
versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663",
active: true,
};
},
} as HubHttpClient;
return {
env: {},
credentials,
hub,
login: { authorize: async () => "paseo_cli_prefix_durable-secret" },
daemon: { connect: async () => daemon },
reporter: { progress() {} },
cwd: () => cwd,
isInteractive: () => true,
prompts,
};
}
class PromptAnswers {
readonly confirmations: string[] = [];
readonly selections: string[] = [];
readonly selectionOptions: string[][] = [];
readonly messages: string[] = [];
constructor(
private readonly confirmAnswers: boolean[],
private readonly selectAnswers: string[],
private readonly textAnswers: string[],
) {}
async confirm(message: string): Promise<boolean> {
this.confirmations.push(message);
return this.confirmAnswers.shift() ?? false;
}
async select(options: {
message: string;
options?: { label: string; hint?: string }[];
}): Promise<string> {
this.selections.push(options.message);
this.selectionOptions.push(
options.options?.map(
(option) => `${option.label}${option.hint ? ` (${option.hint})` : ""}`,
) ?? [],
);
return this.selectAnswers.shift() ?? "";
}
async text(): Promise<string> {
return this.textAnswers.shift() ?? "";
}
message(value: string): void {
this.messages.push(value);
}
}
class MemoryCredentials implements HubCredentialStore {
private record: StoredHubCredential | null = null;
active(): StoredHubCredential | null {
return this.record;
}
get(origin: string): StoredHubCredential | null {
return this.record?.origin === origin ? this.record : null;
}
save(credential: StoredHubCredential): void {
this.record = credential;
}
logoutActive(): StoredHubCredential | null {
const record = this.record;
this.record = null;
return record;
}
}
class SetupDaemon implements HubDaemonClient {
connections = 0;
readonly snapshotCwds: Array<string | undefined> = [];
private origin: string | null = null;
constructor(
readonly providerEntries: readonly ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "ready" as const,
enabled: true,
label: "Codex",
models: [
{ provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true },
{ provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" },
],
modes: [
{ id: "read-only", label: "Read only" },
{ id: "full-access", label: "Full access" },
],
defaultModeId: "full-access",
},
],
private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[],
) {}
async connectHub(origin: string) {
this.connections += 1;
this.origin = origin;
return { status: status(origin) };
}
async getHubStatus() {
return { status: this.origin === null ? disconnectedStatus() : status(this.origin) };
}
async getProvidersSnapshot(options?: { cwd?: string }) {
this.snapshotCwds.push(options?.cwd);
const index = Math.min(
this.snapshotCwds.length - 1,
(this.providerEntrySequence?.length ?? 1) - 1,
);
return { entries: this.providerEntrySequence?.[index] ?? this.providerEntries };
}
async disconnectHub() {
return { status: disconnectedStatus() };
}
async close() {}
}
function status(origin: string): HubStatus {
return {
state: "connected",
daemonId: "daemon-1",
hubOrigin: origin,
scopes: [],
connectedAt: null,
lastError: null,
};
}
function disconnectedStatus(): HubStatus {
return {
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
connectedAt: null,
lastError: null,
};
}
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-flow-"));
directories.push(directory);
return directory;
}
@@ -7,6 +7,7 @@ import { discoverHubBundle } from "./deploy-bundle.js";
import { runHubDeploy } from "./deploy.js";
import {
createHubInitScaffold,
hubLoginResumeCommand,
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
@@ -26,6 +27,12 @@ afterEach(async () => {
});
describe("Hub init planning", () => {
it("prints direct resumable commands for declined login continuations", () => {
expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe(
"paseo hub connect https://hub.test",
);
expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init");
});
it("includes login only when there is no active login", () => {
expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({
replaceExisting: false,
@@ -86,10 +93,25 @@ describe("Hub init planning", () => {
});
describe("Hub init scaffold", () => {
it("omits mode for a provider that explicitly exposes none", () => {
const scaffold = createHubInitScaffold({
cwd: "/workspace",
daemonSlug: "build-studio",
agent: { provider: "pi", model: "default" },
provider: "slack",
providerFilters: { workspace: "T123456", user: "U123456" },
});
expect(YAML.parse(scaffold.hub)).toMatchObject({
agents: { starter: { provider: "pi", model: "default" } },
});
expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined();
});
it.each([
["github", { repo: "getpaseo/paseo", user: "boudra" }],
["slack", { workspace: "paseo", user: "boudra" }],
["discord", { guild: "paseo", user: "boudra" }],
["slack", { workspace: "T123456", user: "U123456" }],
["discord", { guild: "123456789", user: "987654321" }],
] satisfies readonly [HubInitProvider, Record<string, string>][])(
"creates a closed %s trigger that the deploy discovery path accepts",
async (provider, providerFilters) => {
@@ -97,6 +119,11 @@ describe("Hub init scaffold", () => {
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: "build-studio",
agent: {
provider: "codex",
model: "gpt-5",
mode: "full-access",
},
provider,
providerFilters,
});
@@ -114,7 +141,12 @@ describe("Hub init scaffold", () => {
filters: { from_users: string[]; channels?: string[] };
};
expect(parsed.filters.from_users).toEqual([providerFilters.user]);
if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456");
if (provider === "discord") expect(parsed.filters.guild).toBe("123456789");
expect(parsed.filters.channels).toBeUndefined();
expect(YAML.parse(scaffold.hub)).toMatchObject({
agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } },
});
let validatedPaths: readonly string[] = [];
const result = await runHubDeploy(
+11 -4
View File
@@ -3,6 +3,7 @@ import YAML from "yaml";
import type { HubDeployBundle } from "./deploy-bundle.js";
import type { HubProject } from "./hub-client/index.js";
import type { HubStatus } from "./daemon-client.js";
import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js";
export type HubInitProvider = "github" | "slack" | "discord";
@@ -25,6 +26,7 @@ export interface HubInitOpeningPlan {
export interface HubInitScaffoldInput {
cwd: string;
daemonSlug: string;
agent: HubStarterAgentRuntime;
provider: HubInitProvider;
providerFilters: Readonly<Record<string, string>>;
}
@@ -36,6 +38,10 @@ export interface HubInitScaffold {
testAction: string;
}
export function hubLoginResumeCommand(step: "connect" | "init", origin: string): string {
return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init";
}
export function planHubInitOpening(input: {
loggedIn: boolean;
paseoDirectoryExists: boolean;
@@ -97,9 +103,10 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff
},
},
agents: {
codex: {
provider: "codex",
mode: "full-access",
starter: {
provider: input.agent.provider,
model: input.agent.model,
...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }),
},
},
},
@@ -180,7 +187,7 @@ function workflow(input: {
environment: input.environment,
max_runtime: "90m",
idle_timeout: "10m",
agent: "codex",
agent: "starter",
prompt: [
{
text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
+344 -112
View File
@@ -21,28 +21,44 @@ import type { HubCredentialStore } from "./credentials.js";
import type { HubDaemonConnection } from "./daemon-client.js";
import { withHubDaemon } from "./daemon-client.js";
import { HubCommandError } from "./error.js";
import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js";
import type {
HubConfigurationResources,
HubHttpClient,
HubProject,
HubSetupResources,
} from "./hub-client/index.js";
import {
createHubInitBundle,
createHubInitScaffold,
hubLoginResumeCommand,
planHubInitOpening,
resolveHubInitConnection,
resolveHubInitProjects,
type HubInitProvider,
} from "./init-plan.js";
import type { CliLoginFlow } from "./login-flow.js";
import { runHubLogin } from "./login.js";
import { runHubConnect } from "./connect.js";
import { runHubDeployBundle } from "./deploy.js";
import { runHubProjects } from "./projects.js";
import type { HubReporter } from "./reporter.js";
import { normalizeHubOrigin } from "./origin.js";
import {
selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice,
type HubStarterAgentProvider,
type HubStarterAgentRuntime,
} from "./starter-agent-runtime.js";
import {
availableStarterTriggerConnections,
type HubStarterTriggerConnection,
} from "./starter-trigger.js";
const execFileAsync = promisify(execFile);
const DAEMON_READY_TIMEOUT_MS = 60_000;
const DAEMON_READY_POLL_MS = 250;
const PROVIDER_READY_TIMEOUT_MS = 60_000;
interface HubInitEnvironment {
export interface HubGuidedSetupEnvironment {
env: Readonly<Record<string, string | undefined>>;
credentials: HubCredentialStore;
hub: HubHttpClient;
@@ -50,6 +66,19 @@ interface HubInitEnvironment {
daemon: HubDaemonConnection;
reporter: HubReporter;
cwd(): string;
isInteractive?(): boolean;
prompts?: {
confirm(message: string, initialValue: boolean): Promise<boolean>;
select(options: Parameters<typeof select<string>>[0]): Promise<string>;
text(options: Parameters<typeof text>[0]): Promise<string>;
message(value: string): void;
};
}
interface HubGuidedSetupState {
origin?: string;
daemonId?: string;
deploy?: boolean;
}
class HubInitCancelledError extends Error {}
@@ -61,7 +90,7 @@ function initErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void {
export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void {
parent
.command("init")
.description("Create and optionally deploy a safe starter Hub bundle")
@@ -79,8 +108,15 @@ export function addHubInitCommand(parent: Command, environment: HubInitEnvironme
});
}
export async function runHubInit(environment: HubInitEnvironment): Promise<void> {
requireInteractiveTerminal();
export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> {
await runHubGuidedSetup(environment);
}
export async function runHubGuidedSetup(
environment: HubGuidedSetupEnvironment,
state: HubGuidedSetupState = {},
): Promise<void> {
requireInteractiveTerminal(environment);
intro("Set up Paseo Hub");
const cwd = environment.cwd();
@@ -91,29 +127,27 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
});
if (
opening.replaceExisting &&
!(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))
!(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false))
) {
throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
}
const origin = await ensureLogin(activeLogin?.origin, environment);
const daemonId = await ensureDaemonConnection(origin, environment);
const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment));
const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment));
const project = await chooseProject(origin, environment);
const resources = await loadConfigurationResources(origin, environment);
const daemon = resources.daemons.find(({ id }) => id === daemonId);
if (daemon === undefined) {
throw new HubCommandError(
"HUB_DAEMON_RESOURCE_MISSING",
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
);
}
const resources = await loadSetupResources(origin, environment);
const triggerConnections = await resolveStarterTriggerConnections(resources, cwd);
reportStarterTriggerConnections(environment, triggerConnections);
const trigger = await chooseStarterTriggerConnection(environment, triggerConnections);
const daemon = await loadDaemonResource(origin, daemonId, environment);
log.success(`Connected as ${daemon.slug}`);
const provider = await chooseProvider();
const providerFilters = await collectProviderFilters(provider, resources, cwd);
const agent = await chooseStarterAgentRuntime(environment, cwd);
const providerFilters = await collectProviderIdentity(trigger, environment);
const scaffold = createHubInitScaffold({
cwd,
daemonSlug: daemon.slug,
provider,
agent,
provider: trigger.provider,
providerFilters,
});
@@ -132,7 +166,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
await writeScaffold(cwd, scaffold, opening.replaceExisting);
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
const deploy = await requiredConfirm("Deploy now?", true);
const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true));
if (deploy) {
await withSpinner("Deploying bundle", async () => {
await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
@@ -145,7 +179,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
});
log.success("Deployed");
} else {
log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
}
const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
@@ -153,11 +187,46 @@ export async function runHubInit(environment: HubInitEnvironment): Promise<void>
outro(deploy ? "Hub is ready" : "Hub bundle is ready");
}
export async function continueHubGuidedSetup(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<void> {
if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) {
reportMessage(
environment,
`Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`,
);
return;
}
const daemonId = await ensureDaemonConnection(origin, environment, true);
if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) {
reportMessage(
environment,
`Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`,
);
return;
}
try {
await runHubGuidedSetup(environment, { origin, daemonId, deploy: true });
} catch (error) {
if (error instanceof HubInitCancelledError) {
if (environment.prompts === undefined) {
log.message(error.message);
outro("Connect an app to continue");
} else {
environment.prompts.message(error.message);
}
return;
}
throw error;
}
}
async function ensureLogin(
activeOrigin: string | undefined,
environment: HubInitEnvironment,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
const endpoint = await requiredSelect({
const endpoint = await requiredSelect(environment, {
message: "Hub endpoint",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
@@ -169,7 +238,7 @@ async function ensureLogin(
const origin =
endpoint === "hosted"
? DEFAULT_HUB_ORIGIN
: await requiredText({
: await requiredText(environment, {
message: "Custom Hub URL",
initialValue:
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
@@ -189,23 +258,17 @@ async function ensureLogin(
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
await runHubLogin(
normalizedOrigin,
{},
{
env: environment.env,
credentials: environment.credentials,
flow: environment.login,
reporter: environment.reporter,
},
);
environment.reporter.progress(`Logging in to ${normalizedOrigin}`);
const credential = await environment.login.authorize(normalizedOrigin);
environment.credentials.save({ origin: normalizedOrigin, credential });
log.success(`Logged in to ${normalizedOrigin}`);
return normalizedOrigin;
}
async function ensureDaemonConnection(
origin: string,
environment: HubInitEnvironment,
environment: HubGuidedSetupEnvironment,
confirmed = false,
): Promise<string> {
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
daemon.getHubStatus().then((response) => response.status),
@@ -223,9 +286,19 @@ async function ensureDaemonConnection(
`This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`,
);
}
if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
if (
!confirmed &&
!(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true))
) {
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
}
return connectDaemon(origin, environment);
}
async function connectDaemon(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<string> {
await runHubConnect(
origin,
{},
@@ -276,7 +349,10 @@ async function waitForDaemonReady(
);
}
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> {
async function chooseProject(
origin: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubProject> {
const result = await runHubProjects(
{ hub: origin },
{
@@ -295,7 +371,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
if (resolution.kind === "selected") {
return resolution.project;
}
const slug = await requiredSelect({
const slug = await requiredSelect(environment, {
message: "Project",
options: resolution.projects.map((project) => ({
value: project.slug,
@@ -313,78 +389,201 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P
return project;
}
async function chooseProvider(): Promise<HubInitProvider> {
return requiredSelect({
message: "Trigger provider",
options: [
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
{ value: "slack", label: "Slack", hint: "channel mention" },
{ value: "discord", label: "Discord", hint: "channel mention" },
],
});
async function resolveStarterTriggerConnections(
resources: HubSetupResources,
cwd: string,
): Promise<HubStarterTriggerConnection[]> {
const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd);
const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote);
const connections = availableStarterTriggerConnections(resources, repository);
if (connections.length === 0) {
throw new HubInitCancelledError(
"No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.",
);
}
return connections;
}
async function collectProviderFilters(
provider: HubInitProvider,
resources: HubConfigurationResources,
function reportStarterTriggerConnections(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): void {
const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`;
if (environment.prompts === undefined) {
note(details, "Hub app connections ready for this workflow");
return;
}
environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`);
}
async function chooseStarterTriggerConnection(
environment: HubGuidedSetupEnvironment,
connections: readonly HubStarterTriggerConnection[],
): Promise<HubStarterTriggerConnection> {
if (connections.length === 1) {
const connection = connections[0]!;
reportMessage(environment, `Using ${connection.label}`);
return connection;
}
const id = await requiredSelect(environment, {
message: "Trigger connection",
options: connections.map((connection) => ({ value: connection.id, label: connection.label })),
});
const connection = connections.find((candidate) => candidate.id === id);
if (connection === undefined) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_INVALID",
"The selected Hub app connection is no longer available. Run paseo hub init again.",
);
}
return connection;
}
async function chooseStarterAgentRuntime(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<HubStarterAgentRuntime> {
const providers = await waitForStarterAgentProviders(environment, cwd);
const provider = await chooseStarterAgentProvider(environment, providers);
const model = await chooseStarterAgentModel(environment, provider);
const mode = await chooseStarterAgentMode(environment, provider);
const runtime = selectedStarterAgentRuntime(provider, model, mode);
if (runtime === undefined) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
);
}
return runtime;
}
async function waitForStarterAgentProviders(
environment: HubGuidedSetupEnvironment,
cwd: string,
): Promise<readonly HubStarterAgentProvider[]> {
return withSpinner("Discovering agent runtimes", async (reporter) =>
withHubDaemon(environment.daemon, undefined, async (daemon) => {
const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS;
while (true) {
const snapshot = await daemon.getProvidersSnapshot({ cwd });
const state = starterAgentProviderSnapshotState(snapshot.entries);
if (state.kind === "ready") return state.providers;
if (state.kind === "unavailable") {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_REQUIRED",
"No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.",
);
}
if (Date.now() >= deadline) {
throw new HubCommandError(
"HUB_AGENT_RUNTIME_TIMEOUT",
"Agent runtime discovery did not finish within 60 seconds. Check the daemon's provider configuration, then run paseo hub init again.",
);
}
reporter.progress("Waiting for agent runtime discovery");
await delay(DAEMON_READY_POLL_MS);
}
}),
);
}
async function chooseStarterAgentProvider(
environment: HubGuidedSetupEnvironment,
providers: readonly HubStarterAgentProvider[],
): Promise<HubStarterAgentProvider> {
const selected = await requiredSelect(environment, {
message: "Starter agent provider",
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
})),
});
const provider = providers.find((candidate) => candidate.id === selected);
if (provider === undefined) throw invalidStarterAgentSelection();
return provider;
}
async function chooseStarterAgentModel(
environment: HubGuidedSetupEnvironment,
provider: HubStarterAgentProvider,
): Promise<string> {
const suggested = suggestedStarterAgentChoice(provider.models);
const selected = await requiredSelect(environment, {
message: "Starter agent model",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: provider.models.map((model) => ({
value: model.id,
label: model.label,
...(model.suggested ? { hint: "suggested" } : {}),
})),
});
if (!provider.models.some((model) => model.id === selected)) throw invalidStarterAgentSelection();
return selected;
}
async function chooseStarterAgentMode(
environment: HubGuidedSetupEnvironment,
provider: HubStarterAgentProvider,
): Promise<string | undefined> {
if (provider.modes.length === 0) return undefined;
const suggested = suggestedStarterAgentChoice(provider.modes);
const selected = await requiredSelect(environment, {
message: "Starter agent mode",
...(suggested === undefined ? {} : { initialValue: suggested.id }),
options: provider.modes.map((mode) => ({
value: mode.id,
label: mode.label,
...(mode.suggested ? { hint: "suggested" } : {}),
})),
});
if (!provider.modes.some((mode) => mode.id === selected)) throw invalidStarterAgentSelection();
return selected;
}
function invalidStarterAgentSelection(): HubCommandError {
return new HubCommandError(
"HUB_AGENT_RUNTIME_SELECTION_INVALID",
"The selected starter agent runtime is no longer available. Run paseo hub init again.",
);
}
async function collectProviderIdentity(
trigger: HubStarterTriggerConnection,
environment: HubGuidedSetupEnvironment,
): Promise<Readonly<Record<string, string>>> {
if (provider === "github") {
const [login, originRemote] = await Promise.all([
readGhValue(["api", "user", "--jq", ".login"]),
readCommandValue("git", ["remote", "get-url", "origin"], cwd),
]);
const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
if (repo === undefined) {
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_UNDETECTED",
"Could not detect a GitHub repository from the origin remote.",
);
}
if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
const connected = resources.github.flatMap(({ repositories }) => repositories);
throw new HubCommandError(
"HUB_GITHUB_REPOSITORY_NOT_CONNECTED",
`${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`,
);
}
if (trigger.provider === "github") {
const login = await readGhValue(["api", "user", "--jq", ".login"]);
return {
user: await requiredText({
...trigger.filters,
user: await requiredText(environment, {
message: "Your GitHub username (only this user can trigger the bot)",
initialValue: login,
}),
repo,
};
}
if (provider === "slack") {
if (trigger.provider === "slack") {
return {
workspace: await chooseConnection(
"Slack workspace",
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })),
),
user: await requiredText({
message: "Your Slack username (only this user can trigger the bot)",
...trigger.filters,
user: await requiredText(environment, {
message: "Your Slack member ID (only this user can trigger the bot)",
}),
};
}
return {
guild: await chooseConnection(
"Discord server",
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })),
),
user: await requiredText({
message: "Your Discord username (only this user can trigger the bot)",
...trigger.filters,
user: await requiredText(environment, {
message: "Your Discord user ID (only this user can trigger the bot)",
}),
};
}
async function loadConfigurationResources(
async function loadSetupResources(
origin: string,
environment: HubInitEnvironment,
): Promise<HubConfigurationResources> {
environment: HubGuidedSetupEnvironment,
): Promise<HubSetupResources> {
try {
return await withSpinner("Loading Hub connections", () =>
environment.hub.listConfigurationResources(
environment.hub.listSetupResources(
origin,
resolveHubCredential({
options: { origin },
@@ -398,28 +597,35 @@ async function loadConfigurationResources(
if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
throw new HubCommandError(
"HUB_UPDATE_REQUIRED",
"This Hub does not support guided setup. Update the Hub and try again.",
"This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.",
);
}
throw error;
}
}
async function chooseConnection(
message: string,
connections: readonly { slug: string; label: string }[],
): Promise<string> {
if (connections.length === 0) {
async function loadDaemonResource(
origin: string,
daemonId: string,
environment: HubGuidedSetupEnvironment,
): Promise<HubConfigurationResources["daemons"][number]> {
const resources = await environment.hub.listConfigurationResources(
origin,
resolveHubCredential({
options: { origin },
env: environment.env,
credentials: environment.credentials,
origin,
}),
);
const daemon = resources.daemons.find(({ id }) => id === daemonId);
if (daemon === undefined) {
throw new HubCommandError(
"HUB_PROVIDER_CONNECTION_REQUIRED",
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
"HUB_DAEMON_RESOURCE_MISSING",
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
);
}
if (connections.length === 1) return connections[0]!.slug;
return requiredSelect({
message,
options: connections.map(({ slug, label }) => ({ value: slug, label })),
});
return daemon;
}
async function writeScaffold(
@@ -523,36 +729,62 @@ async function withSpinner<T>(
}
}
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> {
const answer = await text({
async function requiredText(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof text>[0],
): Promise<string> {
const request = {
...options,
validate(value) {
validate(value: string | undefined) {
const input = value ?? "";
const customError = options.validate?.(input);
if (customError !== undefined) return customError;
return input.trim().length === 0 ? "A value is required" : undefined;
},
});
};
const answer =
environment.prompts === undefined
? await text(request)
: await environment.prompts.text(request);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer.trim();
}
async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> {
const answer = await confirm({ message, initialValue });
async function requiredConfirm(
environment: HubGuidedSetupEnvironment,
message: string,
initialValue: boolean,
): Promise<boolean> {
const answer =
environment.prompts === undefined
? await confirm({ message, initialValue })
: await environment.prompts.confirm(message, initialValue);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
async function requiredSelect<T extends string>(
environment: HubGuidedSetupEnvironment,
options: Parameters<typeof select<T>>[0],
): Promise<T> {
const answer = await select<T>(options);
const answer =
environment.prompts === undefined
? await select<T>(options)
: ((await environment.prompts.select(options as Parameters<typeof select<string>>[0])) as T);
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
return answer;
}
function requireInteractiveTerminal(): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void {
if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) {
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
}
}
function reportMessage(environment: HubGuidedSetupEnvironment, message: string): void {
if (environment.prompts === undefined) {
log.message(message);
return;
}
environment.prompts.message(message);
}
+10
View File
@@ -25,6 +25,8 @@ interface HubLoginDependencies {
credentials: HubCredentialStore;
flow: Pick<CliLoginFlow, "authorize">;
reporter: HubReporter;
isInteractive?(): boolean;
continueGuidedSetup?(origin: string): Promise<void>;
}
export async function runHubLogin(
@@ -40,6 +42,14 @@ export async function runHubLogin(
reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`);
const credential = await dependencies.flow.authorize(origin);
dependencies.credentials.save({ origin, credential });
reportHubProgress(dependencies.reporter, options, "Logged in");
if (
!options.json &&
dependencies.isInteractive?.() &&
dependencies.continueGuidedSetup !== undefined
) {
await dependencies.continueGuidedSetup(origin);
}
return { type: "single", data: { origin, status: "logged_in" }, schema };
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
availableStarterAgentProviders,
selectedStarterAgentRuntime,
starterAgentProviderSnapshotState,
suggestedStarterAgentChoice,
} from "./starter-agent-runtime.js";
describe("starter agent runtime choices", () => {
it.each([
{
name: "uses a complete daemon default as a staged suggestion",
entries: [
{
provider: "codex",
status: "ready" as const,
enabled: true,
label: "Codex",
models: [
{ provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true },
{ provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" },
],
modes: [
{ id: "read-only", label: "Read only" },
{ id: "full-access", label: "Full access" },
],
defaultModeId: "full-access",
},
],
expected: [
{
id: "codex",
label: "Codex",
models: [
{ id: "gpt-5", label: "GPT-5", suggested: true },
{ id: "gpt-5-mini", label: "GPT-5 mini", suggested: false },
],
modes: [
{ id: "read-only", label: "Read only", suggested: false },
{ id: "full-access", label: "Full access", suggested: true },
],
},
],
},
{
name: "keeps Claude selectable when it has modes but no default mode",
entries: [
{
provider: "claude",
status: "ready" as const,
enabled: true,
label: "Claude",
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
modes: [{ id: "auto", label: "Auto" }],
defaultModeId: null,
},
],
expected: [
{
id: "claude",
label: "Claude",
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }],
},
],
},
{
name: "treats an invalid default mode as no suggestion",
entries: [
{
provider: "claude",
status: "ready" as const,
enabled: true,
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
modes: [{ id: "auto", label: "Auto" }],
defaultModeId: "missing",
},
],
expected: [
{
id: "claude",
label: "claude",
models: [{ id: "sonnet", label: "Sonnet", suggested: true }],
modes: [{ id: "auto", label: "Auto", suggested: false }],
},
],
},
{
name: "excludes unavailable, disabled, and model-less providers",
entries: [
{ provider: "claude", status: "unavailable" as const, enabled: true },
{ provider: "codex", status: "ready" as const, enabled: false, models: [] },
{ provider: "opencode", status: "ready" as const, enabled: true, models: [] },
],
expected: [],
},
])("$name", ({ entries, expected }) => {
expect(availableStarterAgentProviders(entries)).toEqual(expected);
});
it("creates mode-less definitions for providers that expose no modes", () => {
const [provider] = availableStarterAgentProviders([
{
provider: "pi",
status: "ready",
enabled: true,
models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }],
modes: [],
},
]);
expect(provider).toBeDefined();
expect(selectedStarterAgentRuntime(provider!, "default")).toEqual({
provider: "pi",
model: "default",
});
expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined();
});
it("distinguishes discovery in progress from a terminally unusable snapshot", () => {
expect(starterAgentProviderSnapshotState([])).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([{ provider: "claude", status: "loading", enabled: true }]),
).toEqual({ kind: "loading" });
expect(
starterAgentProviderSnapshotState([
{ provider: "claude", status: "unavailable", enabled: true },
]),
).toEqual({ kind: "unavailable" });
});
});
@@ -0,0 +1,88 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
export interface HubStarterAgentRuntime {
provider: string;
model: string;
mode?: string;
}
export interface HubStarterAgentProvider {
id: string;
label: string;
models: readonly HubStarterAgentModel[];
modes: readonly HubStarterAgentMode[];
}
export interface HubStarterAgentModel {
id: string;
label: string;
suggested: boolean;
}
export interface HubStarterAgentMode {
id: string;
label: string;
suggested: boolean;
}
export type HubStarterAgentProviderSnapshotState =
| { kind: "ready"; providers: readonly HubStarterAgentProvider[] }
| { kind: "loading" }
| { kind: "unavailable" };
export function availableStarterAgentProviders(
entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProvider[] {
return entries.flatMap((entry) => {
if (entry.status !== "ready" || !entry.enabled) return [];
const models = (entry.models ?? [])
.filter((model) => model.isSelectable !== false)
.map((model) => ({ id: model.id, label: model.label, suggested: model.isDefault === true }));
if (models.length === 0) return [];
const modes = (entry.modes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
suggested: mode.id === entry.defaultModeId,
}));
return [
{
id: entry.provider,
label: entry.label ?? entry.provider,
models,
modes,
},
];
});
}
export function starterAgentProviderSnapshotState(
entries: readonly ProviderSnapshotEntry[],
): HubStarterAgentProviderSnapshotState {
const providers = availableStarterAgentProviders(entries);
if (providers.length > 0) return { kind: "ready", providers };
if (entries.length === 0) return { kind: "loading" };
if (entries.some((entry) => entry.enabled && entry.status === "loading")) {
return { kind: "loading" };
}
return { kind: "unavailable" };
}
export function suggestedStarterAgentChoice<T extends { suggested: boolean }>(
choices: readonly T[],
): T | undefined {
return choices.find((choice) => choice.suggested);
}
export function selectedStarterAgentRuntime(
provider: HubStarterAgentProvider,
modelId: string,
modeId?: string,
): HubStarterAgentRuntime | undefined {
if (!provider.models.some((model) => model.id === modelId)) return undefined;
if (provider.modes.length === 0) {
return modeId === undefined ? { provider: provider.id, model: modelId } : undefined;
}
if (modeId === undefined || !provider.modes.some((mode) => mode.id === modeId)) return undefined;
return { provider: provider.id, model: modelId, mode: modeId };
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { availableStarterTriggerConnections } from "./starter-trigger.js";
describe("starter trigger connections", () => {
it("returns only concrete connections that can back the generated workflow", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/paseo"],
},
],
slack: [{ teamId: "T123", teamName: "Paseo" }],
discord: [{ guildId: "456", guildName: "Paseo Discord" }],
},
"getpaseo/paseo",
),
).toEqual([
{
id: "github:getpaseo/paseo",
label: "GitHub — getpaseo/paseo",
provider: "github",
filters: { repo: "getpaseo/paseo" },
},
{
id: "slack:T123",
label: "Slack — Paseo",
provider: "slack",
filters: { workspace: "T123" },
},
{
id: "discord:456",
label: "Discord — Paseo Discord",
provider: "discord",
filters: { guild: "456" },
},
]);
});
it("does not offer GitHub when the current repository is not connected", () => {
expect(
availableStarterTriggerConnections(
{
github: [
{
slug: "github-getpaseo",
accountLogin: "getpaseo",
accountType: "Organization",
repositories: ["getpaseo/hub"],
},
],
slack: [],
discord: [],
},
"getpaseo/paseo",
),
).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import type { HubSetupResources } from "./hub-client/index.js";
import type { HubInitProvider } from "./init-plan.js";
export interface HubStarterTriggerConnection {
id: string;
label: string;
provider: HubInitProvider;
filters: Readonly<Record<string, string>>;
}
export function availableStarterTriggerConnections(
resources: HubSetupResources,
githubRepository?: string,
): HubStarterTriggerConnection[] {
return [
...(githubRepository !== undefined &&
resources.github.some(({ repositories }) => repositories.includes(githubRepository))
? [
{
id: `github:${githubRepository}`,
label: `GitHub — ${githubRepository}`,
provider: "github" as const,
filters: { repo: githubRepository },
},
]
: []),
...resources.slack.map(({ teamId, teamName }) => ({
id: `slack:${teamId}`,
label: `Slack — ${teamName}`,
provider: "slack" as const,
filters: { workspace: teamId },
})),
...resources.discord.map(({ guildId, guildName }) => ({
id: `discord:${guildId}`,
label: `Discord — ${guildName}`,
provider: "discord" as const,
filters: { guild: guildId },
})),
];
}
+3
View File
@@ -10,6 +10,9 @@ describe("formatPairingInstructions", () => {
expect(output).toContain(QR);
expect(output.split("\n")).toContain(URL);
expect(output).toContain(
"Treat this pairing link like a password. Anyone with it can access this daemon.",
);
});
it("does not print a QR that would reach the terminal edge", () => {
+1 -1
View File
@@ -33,5 +33,5 @@ function formatQr(qr: string | null, columns: number | undefined): string {
}
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`;
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
}