my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// dpi-tui: a pi-like terminal client for one Durable Agent Network device.
|
|
2
|
+
// dpi-tui <device>[:<session>[:<agent>]] device from ~/.dpi/network.sqlite (seeded from dpi-network.yaml)
|
|
3
|
+
// dpi-tui --url <url> --token <token> [--save <id>]
|
|
4
|
+
// dpi-tui mac-mini --new triage create a session (in the device's first start dir) and attach
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { DpiClient } from "../../dpi-sdk/src/client.ts";
|
|
7
|
+
import { isDpiError } from "../../dpi-sdk/src/errors.ts";
|
|
8
|
+
import { NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
|
|
9
|
+
import { syncConfigToRegistry, tryLoadNetworkConfig } from "../../dpi-sdk/src/node/config.ts";
|
|
10
|
+
import { App, type DeviceRef } from "./app.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE = `usage: dpi-tui <device>[:<session>[:<agent>]] [options]
|
|
13
|
+
dpi-tui --url <url> --token <token> [--save <id>]
|
|
14
|
+
|
|
15
|
+
-s, --session <ref> session id or name (else a picker opens)
|
|
16
|
+
-a, --agent <ref> agent id, "main" or name (default: the session's root agent)
|
|
17
|
+
--new <name> create a session on the device and attach
|
|
18
|
+
--cwd <dir> working directory on the device for --new (absolute, or relative to its first start dir)
|
|
19
|
+
--model <id> model for --new (default: the device's default)
|
|
20
|
+
--pair <CODE|URL> pair THIS device's local backend with a hub before the UI opens
|
|
21
|
+
--hub <origin> hub origin for --pair (else the code's pair URL)
|
|
22
|
+
--force with --pair: switch hubs even if already paired
|
|
23
|
+
--url, --token connect to any dpi-backend directly (also DPI_URL, DPI_TOKEN)
|
|
24
|
+
--save <id> store a --url device in ~/.dpi/network.sqlite under <id>`;
|
|
25
|
+
|
|
26
|
+
function fail(message: string): never {
|
|
27
|
+
process.stderr.write(`${message}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
options: {
|
|
34
|
+
url: { type: "string" },
|
|
35
|
+
token: { type: "string" },
|
|
36
|
+
save: { type: "string" },
|
|
37
|
+
session: { type: "string", short: "s" },
|
|
38
|
+
agent: { type: "string", short: "a" },
|
|
39
|
+
new: { type: "string" },
|
|
40
|
+
cwd: { type: "string" },
|
|
41
|
+
model: { type: "string" },
|
|
42
|
+
pair: { type: "string" },
|
|
43
|
+
hub: { type: "string" },
|
|
44
|
+
force: { type: "boolean" },
|
|
45
|
+
help: { type: "boolean", short: "h" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (values.help) {
|
|
49
|
+
process.stdout.write(`${USAGE}\n`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const registry = new NetworkRegistry();
|
|
54
|
+
const config = tryLoadNetworkConfig(process.env.INIT_CWD ?? process.cwd());
|
|
55
|
+
if (config) syncConfigToRegistry(config, registry);
|
|
56
|
+
|
|
57
|
+
const url = values.url ?? process.env.DPI_URL;
|
|
58
|
+
const [deviceRef = "", sessionRef = values.session, agentRef = values.agent] = (
|
|
59
|
+
positionals[0] ?? (url ? "" : (process.env.DPI_DEVICE ?? ""))
|
|
60
|
+
).split(":");
|
|
61
|
+
|
|
62
|
+
let device: DeviceRef;
|
|
63
|
+
if (url) {
|
|
64
|
+
const token = values.token ?? process.env.DPI_TOKEN ?? "";
|
|
65
|
+
if (!token) fail("--url needs --token (or DPI_TOKEN)");
|
|
66
|
+
device = { id: values.save ?? "adhoc", name: values.save ?? new URL(url).host, url: url.replace(/\/+$/, ""), token };
|
|
67
|
+
} else {
|
|
68
|
+
const known = registry.list().map((d) => d.id);
|
|
69
|
+
if (!deviceRef) fail(`${USAGE}\n\nKnown devices: ${known.join(", ") || "none"}`);
|
|
70
|
+
const row = registry.get(deviceRef);
|
|
71
|
+
if (!row) fail(`Unknown device "${deviceRef}". Known: ${known.join(", ") || "none (add one to dpi-network.yaml)"}`);
|
|
72
|
+
if (!row.token) fail(`No token for ${row.id}. Run: just net register ${row.id}`);
|
|
73
|
+
if (!row.url)
|
|
74
|
+
fail(`Device ${row.id} is paired over the tunnel — it has no LAN URL. Attach through the hub web UI, or pair this machine directly.`);
|
|
75
|
+
device = { id: row.id, name: row.name, url: row.url, token: row.token };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const client = new DpiClient(device.url, device.token);
|
|
79
|
+
const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
|
|
80
|
+
fail(
|
|
81
|
+
isDpiError(e) && e.code === "unauthorized"
|
|
82
|
+
? `${device.id}: token rejected by ${device.url}`
|
|
83
|
+
: `${device.id} is offline at ${device.url} (${(e as Error).message})`,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
if (values.save && url)
|
|
87
|
+
registry.upsert({ id: values.save, name: values.save, url: device.url, kind: "url", source: "ui", token: device.token });
|
|
88
|
+
|
|
89
|
+
let session = sessionRef;
|
|
90
|
+
if (values.new) {
|
|
91
|
+
const cwd = values.cwd ?? (await client.dirs("")).dirs[0]?.path;
|
|
92
|
+
if (!cwd) fail(`${device.id} has no start dirs; pass --cwd`);
|
|
93
|
+
const modelId = values.model ?? info.defaultModel;
|
|
94
|
+
const model = info.models.find((m) => m.id === modelId);
|
|
95
|
+
if (!model) fail(`Unknown model ${modelId}. Available: ${info.models.map((m) => m.id).join(", ")}`);
|
|
96
|
+
const created = await client
|
|
97
|
+
.createSession({ name: values.new, cwd, model: { provider: model.provider, modelId: model.id } })
|
|
98
|
+
.catch((e: Error) => fail(`Could not create the session: ${e.message}`));
|
|
99
|
+
session = created.id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const app = new App(device, client, info, registry);
|
|
103
|
+
|
|
104
|
+
// Bootstrap local pairing (--pair CODE --hub ORIGIN [--force]): runs the same
|
|
105
|
+
// controller as /pair, against the LOCAL backend, before the UI opens. A bare
|
|
106
|
+
// --pair without a value points at the slash command instead of guessing.
|
|
107
|
+
if (values.pair !== undefined || values.hub !== undefined) {
|
|
108
|
+
const { runPairing, parsePairArgs, LocalPairing } = await import("./pairing.ts");
|
|
109
|
+
if (!values.pair) {
|
|
110
|
+
process.stdout.write("--pair needs the code from the hub wizard (or its pair URL).\nInside dpi-tui, run: /pair <CODE> --hub <origin>\n");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
LocalPairing.fromEnv();
|
|
115
|
+
} catch (e) {
|
|
116
|
+
fail((e as Error).message);
|
|
117
|
+
}
|
|
118
|
+
await app.open(session, agentRef);
|
|
119
|
+
await runPairing(app, parsePairArgs([values.pair, ...(values.hub ? ["--hub", values.hub] : []), ...(values.force ? ["--force"] : [])].join(" ")));
|
|
120
|
+
} else {
|
|
121
|
+
await app.open(session, agentRef);
|
|
122
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2-26 — TUI local pairing controller.
|
|
3
|
+
*
|
|
4
|
+
* Contract (plan §P2-26):
|
|
5
|
+
* - Talks ONLY to the local, token-authed dpi-backend (127.0.0.1:DPI_PORT with
|
|
6
|
+
* DPI_TOKEN) — never to the currently selected remote registry target.
|
|
7
|
+
* - The TUI never creates a keypair, signs requests, writes pairing.json or
|
|
8
|
+
* runs a second connector loop: the backend owns all of that (03 §7).
|
|
9
|
+
* - Interrupting (esc/Ctrl-C) cancels the LOCAL operation best-effort and says
|
|
10
|
+
* honestly that the hub side is NOT cancelled by it.
|
|
11
|
+
* - Errors map to actionable text; a --force hub switch carries a warning.
|
|
12
|
+
* - The full fingerprint wraps at the terminal width (40/80/200) — never
|
|
13
|
+
* truncated, and no token is ever rendered.
|
|
14
|
+
*/
|
|
15
|
+
import type { App } from "./app.ts";
|
|
16
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
17
|
+
import { Panel } from "./pickers.ts";
|
|
18
|
+
import { c } from "./theme.ts";
|
|
19
|
+
|
|
20
|
+
export interface PairArgs {
|
|
21
|
+
code?: string;
|
|
22
|
+
pairUrl?: string;
|
|
23
|
+
hub?: string;
|
|
24
|
+
force?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse `/pair [CODE|PAIR_URL] [--hub ORIGIN] [--force]`. */
|
|
28
|
+
export function parsePairArgs(arg: string): PairArgs {
|
|
29
|
+
const out: PairArgs = {};
|
|
30
|
+
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
31
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
32
|
+
const t = tokens[i];
|
|
33
|
+
if (t === "--force") out.force = true;
|
|
34
|
+
else if (t === "--hub") out.hub = tokens[++i];
|
|
35
|
+
else if (t.startsWith("--hub=")) out.hub = t.slice(6);
|
|
36
|
+
else if (t.startsWith("http://") || t.startsWith("https://")) out.pairUrl = t;
|
|
37
|
+
else out.code ??= t;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wrap the full fingerprint into ≤width chunks — readable at 40/80/200 cols. */
|
|
43
|
+
export function wrapFingerprint(fp: string, width: number): string[] {
|
|
44
|
+
const chunk = Math.max(8, Math.min(64, width - 4));
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
for (let i = 0; i < fp.length; i += chunk) lines.push(fp.slice(i, i + chunk));
|
|
47
|
+
return lines.length ? lines : [fp];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Actionable text for every pairing failure code (03 §7). */
|
|
51
|
+
export function describePairingError(e: unknown): string {
|
|
52
|
+
const code = typeof e === "string" ? e.split(":", 1)[0] : (e as { code?: string } | null)?.code;
|
|
53
|
+
if (code) {
|
|
54
|
+
switch (code) {
|
|
55
|
+
case "clock_skew":
|
|
56
|
+
return "This device's clock disagrees with the hub by more than a minute — sync the system clock and retry.";
|
|
57
|
+
case "enrollment_lost":
|
|
58
|
+
return "The hub lost this enrollment (restart or new code). Mint a fresh code in the hub wizard and retry.";
|
|
59
|
+
case "paired_elsewhere":
|
|
60
|
+
case "hub_mismatch":
|
|
61
|
+
return "This device is already paired to another hub. Revoke there first, or use /pair --force to switch hubs.";
|
|
62
|
+
case "revoked":
|
|
63
|
+
return "This pairing was revoked on the hub. Mint a fresh code to pair again.";
|
|
64
|
+
case "hub_required":
|
|
65
|
+
return "No hub configured yet — pass the origin: /pair CODE --hub https://hub.example.com";
|
|
66
|
+
case "busy":
|
|
67
|
+
return "A pairing is already in progress. Cancel it first (esc in the pairing panel).";
|
|
68
|
+
case "code_invalid":
|
|
69
|
+
return "The hub does not know this code — check for typos or mint a new one.";
|
|
70
|
+
case "code_expired":
|
|
71
|
+
return "The code expired. Mint a fresh one in the hub wizard.";
|
|
72
|
+
case "unauthorized":
|
|
73
|
+
return "The local backend rejected DPI_TOKEN — the token must be the device's own (dpi_…), at least 32 chars.";
|
|
74
|
+
default:
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return e instanceof Error ? e.message : String(e);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PairingStatusDTO {
|
|
82
|
+
state: "unpaired" | "pairing" | "paired" | "revoked";
|
|
83
|
+
deviceUid?: string;
|
|
84
|
+
deviceName?: string;
|
|
85
|
+
fingerprint: string;
|
|
86
|
+
hubOrigin?: string;
|
|
87
|
+
deviceId?: string;
|
|
88
|
+
pairingEpoch?: string;
|
|
89
|
+
generation?: number;
|
|
90
|
+
pending?: { hubOrigin: string; startedAt: number; note?: string };
|
|
91
|
+
lastError?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Local authenticated pairing endpoints (never the remote registry target). */
|
|
95
|
+
export class LocalPairing {
|
|
96
|
+
readonly base: string;
|
|
97
|
+
constructor(
|
|
98
|
+
private token: string,
|
|
99
|
+
port: number,
|
|
100
|
+
) {
|
|
101
|
+
this.base = `http://127.0.0.1:${port}`;
|
|
102
|
+
}
|
|
103
|
+
static fromEnv(env: NodeJS.ProcessEnv = process.env): LocalPairing {
|
|
104
|
+
const token = env.DPI_TOKEN ?? "";
|
|
105
|
+
if (token.length < 32)
|
|
106
|
+
throw new Error("DPI_TOKEN is unset — local pairing talks to this machine's dpi-backend and needs its token.");
|
|
107
|
+
return new LocalPairing(token, Number(env.DPI_PORT ?? 4780));
|
|
108
|
+
}
|
|
109
|
+
private async call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
110
|
+
const r = await fetch(`${this.base}${path}`, {
|
|
111
|
+
method,
|
|
112
|
+
headers: {
|
|
113
|
+
authorization: `Bearer ${this.token}`,
|
|
114
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
115
|
+
},
|
|
116
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
117
|
+
});
|
|
118
|
+
const data = (await r.json().catch(() => undefined)) as
|
|
119
|
+
| T
|
|
120
|
+
| { error?: { code?: string; message?: string } }
|
|
121
|
+
| undefined;
|
|
122
|
+
if (!r.ok) {
|
|
123
|
+
const error = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
|
|
124
|
+
const e = new Error(error?.message ?? `${r.status} ${r.statusText}`) as Error & { code?: string; status?: number };
|
|
125
|
+
e.code = error?.code;
|
|
126
|
+
e.status = r.status;
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
return data as T;
|
|
130
|
+
}
|
|
131
|
+
status(): Promise<PairingStatusDTO> {
|
|
132
|
+
return this.call<PairingStatusDTO>("GET", "/v1/pairing");
|
|
133
|
+
}
|
|
134
|
+
start(input: PairArgs): Promise<PairingStatusDTO> {
|
|
135
|
+
return this.call<PairingStatusDTO>("POST", "/v1/pairing/start", {
|
|
136
|
+
...(input.pairUrl ? { pairUrl: input.pairUrl } : { code: input.code }),
|
|
137
|
+
...(input.hub ? { hub: input.hub } : {}),
|
|
138
|
+
...(input.force ? { force: true } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
cancel(): Promise<PairingStatusDTO> {
|
|
142
|
+
return this.call<PairingStatusDTO>("POST", "/v1/pairing/cancel");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Render the pairing panel body. Pure — unit-tested. */
|
|
147
|
+
export function pairingViewLines(
|
|
148
|
+
status: PairingStatusDTO | undefined,
|
|
149
|
+
note: string,
|
|
150
|
+
width: number,
|
|
151
|
+
hubOrigin?: string,
|
|
152
|
+
): string[] {
|
|
153
|
+
const lines: string[] = [c.bold(c.accent("Pair this device"))];
|
|
154
|
+
if (hubOrigin) lines.push(c.muted(`hub: ${hubOrigin}`));
|
|
155
|
+
if (!status) {
|
|
156
|
+
lines.push(c.muted(note));
|
|
157
|
+
return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
158
|
+
}
|
|
159
|
+
lines.push(c.muted(`device: ${status.deviceName ?? status.deviceUid ?? "?"} · uid ${status.deviceUid ?? "?"}`));
|
|
160
|
+
lines.push("");
|
|
161
|
+
lines.push(c.bold("key fingerprint (compare with the hub wizard):"));
|
|
162
|
+
for (const line of wrapFingerprint(status.fingerprint, width)) lines.push(` ${c.secondary(line)}`);
|
|
163
|
+
lines.push("");
|
|
164
|
+
if (status.pending) {
|
|
165
|
+
lines.push(c.warn("Awaiting approval on the hub…"));
|
|
166
|
+
if (status.pending.note) lines.push(c.muted(` ${status.pending.note}`));
|
|
167
|
+
} else if (status.lastError) {
|
|
168
|
+
lines.push(c.err(describePairingError(status.lastError)));
|
|
169
|
+
if (status.state === "paired")
|
|
170
|
+
lines.push(c.muted(`Existing pairing to ${status.hubOrigin} is unchanged.`));
|
|
171
|
+
} else if (status.state === "paired") {
|
|
172
|
+
lines.push(c.ok(`Paired to ${status.hubOrigin} (generation ${status.generation}).`));
|
|
173
|
+
lines.push(c.muted("The connector keeps the tunnel up; /pair again to re-pair."));
|
|
174
|
+
} else if (status.state === "revoked") {
|
|
175
|
+
lines.push(c.warn("Revoked on the hub. /pair CODE to pair again."));
|
|
176
|
+
} else {
|
|
177
|
+
lines.push(c.muted(note));
|
|
178
|
+
}
|
|
179
|
+
if (note && status.state !== "pairing") lines.push(c.muted(note));
|
|
180
|
+
return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const pairingActive = (status: PairingStatusDTO) => !!status.pending || status.state === "pairing";
|
|
184
|
+
|
|
185
|
+
/** What closing the panel reports when there was no local operation left to cancel: a failure stays a failure. */
|
|
186
|
+
function closedIdle(status: PairingStatusDTO): [text: string, error: boolean] {
|
|
187
|
+
if (status.lastError) {
|
|
188
|
+
const kept = status.state === "paired" ? "existing credentials are unchanged." : "nothing was paired.";
|
|
189
|
+
return [`Pairing did not complete: ${describePairingError(status.lastError)} No active local operation to cancel; ${kept}`, true];
|
|
190
|
+
}
|
|
191
|
+
if (status.state === "paired") return [`Pairing panel closed. Paired to ${status.hubOrigin} (generation ${status.generation}).`, false];
|
|
192
|
+
return ["Pairing panel closed. No active local operation to cancel; existing credentials are unchanged.", false];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the local pairing flow in an overlay: start → poll the local status →
|
|
197
|
+
* show origin + full fingerprint + awaiting-approval → stop at paired/revoked.
|
|
198
|
+
* Closing the panel (esc) cancels the LOCAL operation best-effort.
|
|
199
|
+
*/
|
|
200
|
+
export async function runPairing(app: App, args: PairArgs): Promise<void> {
|
|
201
|
+
if (!args.code && !args.pairUrl) {
|
|
202
|
+
app.flash("Usage: /pair CODE --hub https://hub.example.com [--force] — mint a code in the hub wizard first.", true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (args.force) app.flash("Warning: --force re-points this device at a different hub. Old credentials there stay until revoked.", true);
|
|
206
|
+
let local: LocalPairing;
|
|
207
|
+
try {
|
|
208
|
+
local = LocalPairing.fromEnv();
|
|
209
|
+
} catch (e) {
|
|
210
|
+
app.flash(describePairingError(e), true);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let live = true;
|
|
214
|
+
let status: PairingStatusDTO | undefined;
|
|
215
|
+
let note = "Starting…";
|
|
216
|
+
let hubOrigin: string | undefined;
|
|
217
|
+
try {
|
|
218
|
+
hubOrigin = args.pairUrl ? new URL(args.pairUrl).origin : args.hub;
|
|
219
|
+
} catch {
|
|
220
|
+
hubOrigin = args.hub;
|
|
221
|
+
}
|
|
222
|
+
const starting = local.start(args);
|
|
223
|
+
const panel = new Panel(
|
|
224
|
+
(w) => pairingViewLines(status, note, w, hubOrigin),
|
|
225
|
+
() => app.closeOverlay(),
|
|
226
|
+
);
|
|
227
|
+
app.overlay(panel, () => {
|
|
228
|
+
if (!live) return;
|
|
229
|
+
live = false;
|
|
230
|
+
void (async () => {
|
|
231
|
+
// A slow start may not have created its local operation yet. Wait for its
|
|
232
|
+
// response before deciding, instead of letting it outlive this panel.
|
|
233
|
+
let started: PairingStatusDTO;
|
|
234
|
+
try {
|
|
235
|
+
started = await starting;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// The backend answered and refused: nothing started, and the refusal was already shown.
|
|
238
|
+
if (typeof (e as { status?: unknown }).status === "number") return;
|
|
239
|
+
app.flash("Pairing panel closed — local cancel was not confirmed. Check the local backend and hub; the hub-side code was not cancelled.", true);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const last = status ?? started;
|
|
243
|
+
if (!pairingActive(last)) {
|
|
244
|
+
const [text, error] = closedIdle(last);
|
|
245
|
+
app.flash(text, error);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Best-effort LOCAL cancel; the hub side is NOT cancelled by this.
|
|
249
|
+
try {
|
|
250
|
+
await local.cancel();
|
|
251
|
+
app.flash("Pairing panel closed — the local operation was cancelled. The hub-side code stays until it expires; revoke there if it was already approved.", true);
|
|
252
|
+
} catch {
|
|
253
|
+
app.flash("Pairing panel closed — local cancel was not confirmed. Check the local backend and hub; the hub-side code was not cancelled.", true);
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
});
|
|
257
|
+
const render = () => {
|
|
258
|
+
panel.invalidate();
|
|
259
|
+
app.tui.invalidate();
|
|
260
|
+
app.tui.requestRender();
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
status = await starting;
|
|
264
|
+
if (!live) return;
|
|
265
|
+
note = "";
|
|
266
|
+
render();
|
|
267
|
+
const deadline = Date.now() + 330_000;
|
|
268
|
+
while (live && Date.now() < deadline) {
|
|
269
|
+
if (!pairingActive(status)) break;
|
|
270
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
271
|
+
if (!live) break;
|
|
272
|
+
status = await local.status();
|
|
273
|
+
if (!live) break;
|
|
274
|
+
render();
|
|
275
|
+
}
|
|
276
|
+
if (live && status && pairingActive(status)) {
|
|
277
|
+
note = "Still waiting after 5 minutes — mint a fresh code if the hub wizard was closed.";
|
|
278
|
+
render();
|
|
279
|
+
}
|
|
280
|
+
} catch (e) {
|
|
281
|
+
if (live) {
|
|
282
|
+
note = describePairingError(e);
|
|
283
|
+
render();
|
|
284
|
+
app.flash(note, true);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Overlays: pick a session, agent, model or device; walk a remote directory; type a name.
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Container,
|
|
5
|
+
Input,
|
|
6
|
+
matchesKey,
|
|
7
|
+
SelectList,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
type Component,
|
|
10
|
+
type Focusable,
|
|
11
|
+
type SelectItem,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { agentTree, type TreeNode } from "../../dpi-sdk/src/models.ts";
|
|
14
|
+
import type { App } from "./app.ts";
|
|
15
|
+
import { c, selectTheme } from "./theme.ts";
|
|
16
|
+
|
|
17
|
+
const panelBg = (s: string) => `\x1b[48;2;37;26;27m${s}\x1b[49m`;
|
|
18
|
+
|
|
19
|
+
class Title implements Component {
|
|
20
|
+
constructor(public text: string) {}
|
|
21
|
+
render(width: number) {
|
|
22
|
+
return [truncateToWidth(this.text, width)];
|
|
23
|
+
}
|
|
24
|
+
invalidate() {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A titled SelectList; typing filters it. */
|
|
28
|
+
export class Picker extends Box {
|
|
29
|
+
readonly list: SelectList;
|
|
30
|
+
private filter = "";
|
|
31
|
+
private title: Title;
|
|
32
|
+
constructor(
|
|
33
|
+
private label: string,
|
|
34
|
+
items: SelectItem[],
|
|
35
|
+
onPick: (item: SelectItem) => void,
|
|
36
|
+
onCancel: () => void,
|
|
37
|
+
hint = "↑↓ move │ enter select │ type to filter │ esc close",
|
|
38
|
+
) {
|
|
39
|
+
super(1, 0, panelBg);
|
|
40
|
+
this.title = new Title(c.bold(c.accent(label)));
|
|
41
|
+
this.list = new SelectList(items, Math.max(1, Math.min(14, items.length)), selectTheme);
|
|
42
|
+
this.list.onSelect = onPick;
|
|
43
|
+
this.list.onCancel = onCancel;
|
|
44
|
+
this.addChild(this.title);
|
|
45
|
+
this.addChild(this.list);
|
|
46
|
+
this.addChild(new Title(c.muted(hint)));
|
|
47
|
+
}
|
|
48
|
+
handleInput(data: string) {
|
|
49
|
+
if (matchesKey(data, "backspace")) this.filter = this.filter.slice(0, -1);
|
|
50
|
+
else if (data.length === 1 && data >= " " && data !== "\x7f") this.filter += data;
|
|
51
|
+
else return this.list.handleInput(data);
|
|
52
|
+
this.list.setFilter(this.filter);
|
|
53
|
+
this.title.text = c.bold(c.accent(this.label)) + (this.filter ? c.muted(` /${this.filter}`) : "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One line of text input. */
|
|
58
|
+
export class Prompt extends Box implements Focusable {
|
|
59
|
+
readonly input = new Input();
|
|
60
|
+
private _focused = false;
|
|
61
|
+
get focused() {
|
|
62
|
+
return this._focused;
|
|
63
|
+
}
|
|
64
|
+
set focused(value: boolean) {
|
|
65
|
+
this._focused = value;
|
|
66
|
+
this.input.focused = value;
|
|
67
|
+
}
|
|
68
|
+
constructor(label: string, initial: string, onSubmit: (value: string) => void, onCancel: () => void) {
|
|
69
|
+
super(1, 0, panelBg);
|
|
70
|
+
this.addChild(new Title(c.bold(c.accent(label))));
|
|
71
|
+
this.input.setValue(initial);
|
|
72
|
+
this.input.onSubmit = onSubmit;
|
|
73
|
+
this.input.onEscape = onCancel;
|
|
74
|
+
this.addChild(this.input);
|
|
75
|
+
this.addChild(new Title(c.muted("enter confirm │ esc cancel")));
|
|
76
|
+
}
|
|
77
|
+
handleInput(data: string) {
|
|
78
|
+
this.input.handleInput(data);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A read-only panel that closes on Esc or Enter (help, tasks). */
|
|
83
|
+
export class Panel extends Box {
|
|
84
|
+
private body = new Container();
|
|
85
|
+
constructor(
|
|
86
|
+
private render_: (width: number) => string[],
|
|
87
|
+
private onClose: () => void,
|
|
88
|
+
) {
|
|
89
|
+
super(1, 0, panelBg);
|
|
90
|
+
this.addChild({ render: (w: number) => this.render_(w).map((l) => truncateToWidth(l, w)), invalidate() {} });
|
|
91
|
+
this.addChild(this.body);
|
|
92
|
+
}
|
|
93
|
+
handleInput(data: string) {
|
|
94
|
+
if (matchesKey(data, "escape") || matchesKey(data, "enter") || data === "q") this.onClose();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ------------------------------------------------------------------ flows
|
|
99
|
+
|
|
100
|
+
export async function pickSession(app: App) {
|
|
101
|
+
const tree = await app.client.tree();
|
|
102
|
+
const items: SelectItem[] = [
|
|
103
|
+
{ value: "+new", label: "+ New session", description: `choose a directory on ${app.device.id}` },
|
|
104
|
+
...tree.sessions.map((s) => ({
|
|
105
|
+
value: s.id,
|
|
106
|
+
label: s.name,
|
|
107
|
+
description: `${s.cwd.split("/").slice(-2).join("/")} │ ${s.agents.length} agent${s.agents.length === 1 ? "" : "s"}${s.busy ? " │ working" : ""}`,
|
|
108
|
+
})),
|
|
109
|
+
];
|
|
110
|
+
app.overlay(
|
|
111
|
+
new Picker(`Sessions on ${app.device.id}`, items, (item) => {
|
|
112
|
+
app.closeOverlay();
|
|
113
|
+
if (item.value === "+new") return void newSession(app);
|
|
114
|
+
const s = tree.sessions.find((x) => x.id === item.value)!;
|
|
115
|
+
const root = s.agents.find((a) => a.kind === "root") ?? s.agents[0];
|
|
116
|
+
void app.attach(s.id, root?.id ?? 1);
|
|
117
|
+
}, () => app.closeOverlay(!app.sessionId)),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function newSession(app: App) {
|
|
122
|
+
const name = await app.prompt("Session name", "");
|
|
123
|
+
if (name === undefined) return app.sessionId ? undefined : pickSession(app);
|
|
124
|
+
const cwd = await walkDirs(app, "");
|
|
125
|
+
if (!cwd) return app.sessionId ? undefined : pickSession(app);
|
|
126
|
+
const model = await chooseModel(app, app.info.defaultModel);
|
|
127
|
+
if (!model) return;
|
|
128
|
+
await app.run(async () => {
|
|
129
|
+
const s = await app.client.createSession({ name: name || "New session", cwd, model: { provider: model.provider, modelId: model.id } });
|
|
130
|
+
await app.attach(s.id, s.agents.find((a) => a.kind === "root")?.id ?? 1);
|
|
131
|
+
app.flash(`Session created in ${cwd}`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function walkDirs(app: App, path: string): Promise<string | undefined> {
|
|
136
|
+
return new Promise((done) => {
|
|
137
|
+
const step = async (at: string) => {
|
|
138
|
+
const listing = await app.client.dirs(at).catch((e: Error) => (app.flash(e.message, true), undefined));
|
|
139
|
+
if (!listing) return done(undefined);
|
|
140
|
+
const items: SelectItem[] = [
|
|
141
|
+
...(listing.path ? [{ value: `use:${listing.path}`, label: `✓ Use ${listing.path}` }] : []),
|
|
142
|
+
...(listing.path ? [{ value: listing.parent ?? "", label: listing.parent ? ".." : "⌂ start dirs" }] : []),
|
|
143
|
+
...listing.dirs.map((d) => ({ value: d.path, label: `${d.name}/` })),
|
|
144
|
+
];
|
|
145
|
+
app.overlay(
|
|
146
|
+
new Picker(`Working directory on ${app.device.id}`, items, (item) => {
|
|
147
|
+
app.closeOverlay();
|
|
148
|
+
if (item.value.startsWith("use:")) done(item.value.slice(4));
|
|
149
|
+
else void step(item.value);
|
|
150
|
+
}, () => {
|
|
151
|
+
app.closeOverlay();
|
|
152
|
+
done(undefined);
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
void step(path);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function chooseModel(app: App, current?: string) {
|
|
161
|
+
return new Promise<(typeof app.info.models)[number] | undefined>((done) => {
|
|
162
|
+
const models = app.info.models;
|
|
163
|
+
const picker = new Picker(
|
|
164
|
+
`Model (${app.device.id})`,
|
|
165
|
+
models.map((m) => ({ value: `${m.provider}/${m.id}`, label: m.name, description: `${m.tier} │ ${m.provider}` })),
|
|
166
|
+
(item) => {
|
|
167
|
+
app.closeOverlay();
|
|
168
|
+
done(models.find((m) => `${m.provider}/${m.id}` === item.value));
|
|
169
|
+
},
|
|
170
|
+
() => {
|
|
171
|
+
app.closeOverlay();
|
|
172
|
+
done(undefined);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const index = models.findIndex((m) => m.id === current);
|
|
176
|
+
if (index >= 0) picker.list.setSelectedIndex(index);
|
|
177
|
+
app.overlay(picker);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function pickModel(app: App) {
|
|
182
|
+
if (!app.handle) return;
|
|
183
|
+
const model = await chooseModel(app, app.model.state.agent.model?.modelId);
|
|
184
|
+
if (!model) return;
|
|
185
|
+
await app.run(async () => {
|
|
186
|
+
await app.handle!.configure({ model: { provider: model.provider, modelId: model.id } });
|
|
187
|
+
app.flash(`Model: ${model.name} (next turn)`);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function pickAgent(app: App) {
|
|
192
|
+
const summary = app.sessionSummary();
|
|
193
|
+
if (!summary) return;
|
|
194
|
+
const rows: SelectItem[] = [];
|
|
195
|
+
const walk = (nodes: TreeNode[], depth: number) => {
|
|
196
|
+
for (const node of nodes) {
|
|
197
|
+
if (node.type !== "agent") continue;
|
|
198
|
+
const a = node.agent;
|
|
199
|
+
const icon = a.kind === "fork" ? "⑂" : a.kind === "subagent" ? "↳" : "◆";
|
|
200
|
+
rows.push({
|
|
201
|
+
value: String(a.id),
|
|
202
|
+
label: `${" ".repeat(depth)}${icon} ${a.name}${a.busy ? " ●" : ""}`,
|
|
203
|
+
description: [a.kind === "fork" ? `fork @ #${a.forkedAt}` : a.kind, a.background ? `${a.background} bg` : ""]
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join(" │ "),
|
|
206
|
+
});
|
|
207
|
+
walk(node.children, depth + 1);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
walk(agentTree(app.device.id, summary, false), 0);
|
|
211
|
+
const picker = new Picker(`Agents in ${summary.name}`, rows, (item) => {
|
|
212
|
+
app.closeOverlay();
|
|
213
|
+
void app.attach(summary.id, Number(item.value));
|
|
214
|
+
}, () => app.closeOverlay());
|
|
215
|
+
const index = rows.findIndex((r) => r.value === String(app.agentId));
|
|
216
|
+
if (index >= 0) picker.list.setSelectedIndex(index);
|
|
217
|
+
app.overlay(picker);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function pickDevice(app: App) {
|
|
221
|
+
const devices = app.registry?.list() ?? [];
|
|
222
|
+
if (!devices.length) return app.flash("No saved devices. Use --url/--token --save <id>.", true);
|
|
223
|
+
app.overlay(
|
|
224
|
+
new Picker(
|
|
225
|
+
"Devices",
|
|
226
|
+
devices.map((d) => ({
|
|
227
|
+
value: d.id,
|
|
228
|
+
label: `${d.id === app.device.id ? "● " : " "}${d.id}`,
|
|
229
|
+
description: `${d.kind} │ ${d.url}${d.token ? "" : " │ no token"}`,
|
|
230
|
+
})),
|
|
231
|
+
(item) => {
|
|
232
|
+
app.closeOverlay();
|
|
233
|
+
void app.switchDevice(item.value);
|
|
234
|
+
},
|
|
235
|
+
() => app.closeOverlay(),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|