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,129 @@
|
|
|
1
|
+
// Vue bindings over the SDK models. Each model stays a plain object (no deep proxies); a version
|
|
2
|
+
// counter bumps on every change so computed views re-evaluate once per frame.
|
|
3
|
+
import { computed, ref, shallowRef, watch, type Ref } from "vue";
|
|
4
|
+
import { AgentModel, NetworkModel, SessionModel } from "@sdk/models";
|
|
5
|
+
import type { Activity, Frame } from "@sdk/api";
|
|
6
|
+
import type { AppState } from "@sdk/app-state";
|
|
7
|
+
import type { HubStream } from "./stream";
|
|
8
|
+
|
|
9
|
+
export interface SessionTarget {
|
|
10
|
+
deviceId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentTarget extends SessionTarget {
|
|
14
|
+
agentId: number;
|
|
15
|
+
}
|
|
16
|
+
export type FeedStatus = "idle" | "connecting" | "live" | "reconnecting" | "fault";
|
|
17
|
+
|
|
18
|
+
export const AGENT_DOCS = ["demo.job-runs", "demo.workspace"];
|
|
19
|
+
export const agentSub = (t: AgentTarget) =>
|
|
20
|
+
`${t.deviceId}/agent/${t.sessionId}/${t.agentId}?docs=${AGENT_DOCS.join(",")}`;
|
|
21
|
+
export const sessionSub = (t: SessionTarget) => `${t.deviceId}/session/${t.sessionId}`;
|
|
22
|
+
export const deviceSub = (id: string) => `${id}/device?docs=app.shared-state`;
|
|
23
|
+
|
|
24
|
+
/** Shared bookkeeping for one stream slot: hello → live, disconnected → reconnecting, fault. */
|
|
25
|
+
function feed(
|
|
26
|
+
stream: HubStream,
|
|
27
|
+
slot: "device" | "session" | "agent",
|
|
28
|
+
apply: (frame: Frame) => void,
|
|
29
|
+
readyOn: "hello" | "snapshot" = "hello",
|
|
30
|
+
) {
|
|
31
|
+
const status = ref<FeedStatus>("idle");
|
|
32
|
+
const fault = ref("");
|
|
33
|
+
stream.route(slot, (frame) => {
|
|
34
|
+
if (frame.type === "hello") {
|
|
35
|
+
status.value = readyOn === "hello" ? "live" : "connecting";
|
|
36
|
+
if (readyOn === "hello") fault.value = "";
|
|
37
|
+
} else if (frame.type === "disconnected") status.value = "reconnecting";
|
|
38
|
+
else if (frame.type === "fault") {
|
|
39
|
+
status.value = "fault";
|
|
40
|
+
fault.value = frame.error.message;
|
|
41
|
+
} else {
|
|
42
|
+
apply(frame);
|
|
43
|
+
if (frame.type === readyOn) {
|
|
44
|
+
fault.value = "";
|
|
45
|
+
status.value = "live";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const select = (key?: string) => {
|
|
50
|
+
fault.value = "";
|
|
51
|
+
status.value = key ? "connecting" : "idle";
|
|
52
|
+
stream.set(slot, key);
|
|
53
|
+
};
|
|
54
|
+
const retry = () => {
|
|
55
|
+
status.value = "connecting";
|
|
56
|
+
stream.reopen(slot);
|
|
57
|
+
};
|
|
58
|
+
return { status, fault, select, retry };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useNetwork(stream: HubStream) {
|
|
62
|
+
const model = new NetworkModel();
|
|
63
|
+
const version = ref(0);
|
|
64
|
+
model.subscribe(() => version.value++);
|
|
65
|
+
stream.onNetwork((devices) => model.apply({ type: "network", devices }));
|
|
66
|
+
const devices = computed(() => (version.value, model.devices));
|
|
67
|
+
const tree = computed(() => (version.value, model.tree()));
|
|
68
|
+
return { model, devices, tree };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useDevice(stream: HubStream, deviceId: Ref<string | undefined>) {
|
|
72
|
+
const sharedState = shallowRef<{ value: AppState; revision: number }>();
|
|
73
|
+
const activity = shallowRef<Activity[]>([]);
|
|
74
|
+
const { status, select } = feed(stream, "device", (frame) => {
|
|
75
|
+
if (frame.type === "doc" && frame.kind === "app.shared-state")
|
|
76
|
+
sharedState.value = { value: frame.value as AppState, revision: frame.revision ?? 0 };
|
|
77
|
+
else if (frame.type === "activity")
|
|
78
|
+
activity.value = [...activity.value, ...frame.items].slice(-50);
|
|
79
|
+
});
|
|
80
|
+
watch(
|
|
81
|
+
deviceId,
|
|
82
|
+
(id) => {
|
|
83
|
+
sharedState.value = undefined;
|
|
84
|
+
select(id ? deviceSub(id) : undefined);
|
|
85
|
+
},
|
|
86
|
+
{ immediate: true },
|
|
87
|
+
);
|
|
88
|
+
return { sharedState, activity, status };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useSession(stream: HubStream, target: Ref<SessionTarget | undefined>) {
|
|
92
|
+
const model = new SessionModel();
|
|
93
|
+
const version = ref(0);
|
|
94
|
+
model.subscribe(() => version.value++);
|
|
95
|
+
const { status, fault, select } = feed(stream, "session", (frame) => model.apply(frame));
|
|
96
|
+
watch(
|
|
97
|
+
() => (target.value ? sessionSub(target.value) : undefined),
|
|
98
|
+
(key) => {
|
|
99
|
+
model.clear();
|
|
100
|
+
select(key);
|
|
101
|
+
},
|
|
102
|
+
{ immediate: true },
|
|
103
|
+
);
|
|
104
|
+
const state = computed(() => (version.value, model.state));
|
|
105
|
+
return { model, state, status, fault };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useAgent(stream: HubStream, target: Ref<AgentTarget | undefined>) {
|
|
109
|
+
const model = new AgentModel();
|
|
110
|
+
const version = ref(0);
|
|
111
|
+
model.subscribe(() => version.value++);
|
|
112
|
+
const { status, fault, select, retry } = feed(
|
|
113
|
+
stream,
|
|
114
|
+
"agent",
|
|
115
|
+
(frame) => model.apply(frame),
|
|
116
|
+
"snapshot",
|
|
117
|
+
);
|
|
118
|
+
watch(
|
|
119
|
+
() => (target.value ? agentSub(target.value) : undefined),
|
|
120
|
+
(key) => {
|
|
121
|
+
model.clear();
|
|
122
|
+
select(key);
|
|
123
|
+
},
|
|
124
|
+
{ immediate: true },
|
|
125
|
+
);
|
|
126
|
+
// A fresh object per version: the model mutates its state in place.
|
|
127
|
+
const state = computed(() => (version.value, { ...model.state }));
|
|
128
|
+
return { model, state, status, fault, version, retry };
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Commands. Device calls go through the hub proxy (it injects the token, so the browser never
|
|
2
|
+
// holds one); status checks and UI state go to the hub itself.
|
|
3
|
+
import { DpiClient } from "@sdk/client";
|
|
4
|
+
import { DpiError } from "@sdk/errors";
|
|
5
|
+
import type { ErrorCode, NetworkDevice } from "@sdk/api";
|
|
6
|
+
|
|
7
|
+
export const deviceClient = (id: string) => new DpiClient(`/api/devices/${encodeURIComponent(id)}`);
|
|
8
|
+
|
|
9
|
+
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
10
|
+
const r = await fetch(`/api${path}`, {
|
|
11
|
+
method,
|
|
12
|
+
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
13
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
const data = await r.json().catch(() => undefined);
|
|
16
|
+
if (!r.ok) {
|
|
17
|
+
const error = (data as { error?: { code?: ErrorCode; message?: string } } | undefined)?.error;
|
|
18
|
+
throw new DpiError(
|
|
19
|
+
error?.code ?? "internal",
|
|
20
|
+
error?.message ?? `${r.status} ${r.statusText}`,
|
|
21
|
+
r.status,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return data as T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const hub = {
|
|
28
|
+
network: () => call<{ configFile?: string; devices: NetworkDevice[] }>("GET", "/network"),
|
|
29
|
+
/** Probe a device now. Devices themselves are configured in dpi-network.yaml, not here. */
|
|
30
|
+
check: (id: string) =>
|
|
31
|
+
call<NetworkDevice>("POST", `/devices/${encodeURIComponent(id)}/check`, {}),
|
|
32
|
+
async uiGet<T>(key: string): Promise<T | undefined> {
|
|
33
|
+
return ((await call<{ value: T | null }>("GET", `/ui-state/${encodeURIComponent(key)}`))
|
|
34
|
+
.value ?? undefined) as T | undefined;
|
|
35
|
+
},
|
|
36
|
+
uiSet: (key: string, value: unknown) =>
|
|
37
|
+
call<{ ok: true }>("PUT", `/ui-state/${encodeURIComponent(key)}`, { value }),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const message = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const DEFAULT_ICON = "lab.png";
|
|
2
|
+
|
|
3
|
+
/** The icon name is device-controlled; only a plain png/svg file name may become part of the path. */
|
|
4
|
+
export function iconPath(name?: unknown) {
|
|
5
|
+
const ok = typeof name === "string" && /^[\w.-]+\.(png|svg)$/.test(name) && !name.includes("..");
|
|
6
|
+
return `/icons/${ok ? name : DEFAULT_ICON}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Durable jobs in the web client: the device's job definitions, this agent's runs (the `demo.job-runs`
|
|
2
|
+
// document), and the actions a run card or the Tasks tab can take.
|
|
3
|
+
import { computed, ref } from "vue";
|
|
4
|
+
import { JOB_RUNS_DOC, type JobDef, type JobRun, type JobRunsDoc } from "@sdk/jobs";
|
|
5
|
+
import {
|
|
6
|
+
agentHandle,
|
|
7
|
+
agentState,
|
|
8
|
+
attempt,
|
|
9
|
+
device,
|
|
10
|
+
hideInspector,
|
|
11
|
+
narrow,
|
|
12
|
+
notify,
|
|
13
|
+
select,
|
|
14
|
+
selected,
|
|
15
|
+
sessionHandle,
|
|
16
|
+
} from "./store";
|
|
17
|
+
|
|
18
|
+
export const jobDefs = computed<JobDef[]>(() => device.value?.info?.jobs ?? []);
|
|
19
|
+
export const jobDef = (id: string) => jobDefs.value.find((d) => d.id === id);
|
|
20
|
+
|
|
21
|
+
/** This agent's runs, newest first. */
|
|
22
|
+
export const jobRuns = computed<JobRun[]>(() => {
|
|
23
|
+
const doc = agentState.value.docs[JOB_RUNS_DOC] as JobRunsDoc | undefined;
|
|
24
|
+
return Object.values(doc?.items ?? {}).sort((a, b) => b.id - a.id);
|
|
25
|
+
});
|
|
26
|
+
export const runById = (id: number) => jobRuns.value.find((r) => r.id === id);
|
|
27
|
+
export const runByCall = (callId: string) => jobRuns.value.find((r) => r.toolCallId === callId);
|
|
28
|
+
|
|
29
|
+
/** One clock for every elapsed timer on the page. */
|
|
30
|
+
export const now = ref(Date.now());
|
|
31
|
+
setInterval(() => (now.value = Date.now()), 1000);
|
|
32
|
+
|
|
33
|
+
export const seconds = (ms: number) =>
|
|
34
|
+
ms < 1000
|
|
35
|
+
? `${Math.max(0, Math.round(ms))}ms`
|
|
36
|
+
: ms < 60_000
|
|
37
|
+
? `${(ms / 1000).toFixed(1)}s`
|
|
38
|
+
: `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
|
39
|
+
|
|
40
|
+
export const pending = ref<string>();
|
|
41
|
+
|
|
42
|
+
async function act<T>(key: string, fn: () => Promise<T>): Promise<T | undefined> {
|
|
43
|
+
if (pending.value) return;
|
|
44
|
+
pending.value = key;
|
|
45
|
+
try {
|
|
46
|
+
return await attempt(fn);
|
|
47
|
+
} finally {
|
|
48
|
+
pending.value = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Scroll the chat to a run's card (it renders where the run started). */
|
|
53
|
+
export function revealRun(id: number) {
|
|
54
|
+
if (narrow.value) hideInspector();
|
|
55
|
+
setTimeout(() =>
|
|
56
|
+
document
|
|
57
|
+
.querySelector(`[data-job-run="${id}"]`)
|
|
58
|
+
?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function startJob(def: JobDef) {
|
|
63
|
+
const started = await act(`start:${def.id}`, () =>
|
|
64
|
+
agentHandle.value!.demo<{ taskId: number }>("jobs", { job: def.id }),
|
|
65
|
+
);
|
|
66
|
+
if (started) {
|
|
67
|
+
notify(`${def.title} started. It runs in the chat.`);
|
|
68
|
+
revealRun(started.taskId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const decide = (run: JobRun, approved: boolean) =>
|
|
73
|
+
act(`decide:${run.id}`, () => agentHandle.value!.demo(`jobs/${run.id}/approval`, { approved }));
|
|
74
|
+
|
|
75
|
+
export const stopRun = (run: JobRun) =>
|
|
76
|
+
act(`stop:${run.id}`, () => sessionHandle.value!.abortTask(run.id));
|
|
77
|
+
|
|
78
|
+
export function openAgent(conversationId: number) {
|
|
79
|
+
if (selected.value) select({ ...selected.value, agentId: conversationId });
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript text is written by a device (or by an agent a device can prompt-inject)
|
|
3
|
+
* and is rendered inside the hub's own origin. A link in it must therefore never lead
|
|
4
|
+
* back into the hub: one click is a top-level navigation to a hub path under the
|
|
5
|
+
* operator's credentials. Only absolute http(s) links to OTHER origins survive.
|
|
6
|
+
*/
|
|
7
|
+
export function externalHref(href: string, hubOrigin: string): string | null {
|
|
8
|
+
let url: URL;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(href.trim()); // no base: relative and protocol-relative forms throw and are dropped
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
15
|
+
return url.origin === hubOrigin ? null : url.href;
|
|
16
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// What survives sanitising of transcript markdown. The text is written by a device or by an agent
|
|
2
|
+
// a device can prompt-inject, and it is rendered inside the admin origin: anything beyond the
|
|
3
|
+
// markdown subset (styles, forms, overlays, remote images) is an injection or phishing surface.
|
|
4
|
+
|
|
5
|
+
export const ALLOWED_TAGS = [
|
|
6
|
+
"p",
|
|
7
|
+
"br",
|
|
8
|
+
"hr",
|
|
9
|
+
"strong",
|
|
10
|
+
"b",
|
|
11
|
+
"em",
|
|
12
|
+
"i",
|
|
13
|
+
"del",
|
|
14
|
+
"s",
|
|
15
|
+
"code",
|
|
16
|
+
"pre",
|
|
17
|
+
"blockquote",
|
|
18
|
+
"ul",
|
|
19
|
+
"ol",
|
|
20
|
+
"li",
|
|
21
|
+
"a",
|
|
22
|
+
"h1",
|
|
23
|
+
"h2",
|
|
24
|
+
"h3",
|
|
25
|
+
"h4",
|
|
26
|
+
"h5",
|
|
27
|
+
"h6",
|
|
28
|
+
"small",
|
|
29
|
+
"sub",
|
|
30
|
+
"sup",
|
|
31
|
+
"table",
|
|
32
|
+
"thead",
|
|
33
|
+
"tbody",
|
|
34
|
+
"tr",
|
|
35
|
+
"th",
|
|
36
|
+
"td",
|
|
37
|
+
"img",
|
|
38
|
+
// Only the disabled GFM task-list checkbox; anything else is removed by the attribute policy.
|
|
39
|
+
"input",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const FORBIDDEN_TAGS = [
|
|
43
|
+
"form",
|
|
44
|
+
"style",
|
|
45
|
+
"script",
|
|
46
|
+
"button",
|
|
47
|
+
"select",
|
|
48
|
+
"textarea",
|
|
49
|
+
"iframe",
|
|
50
|
+
"object",
|
|
51
|
+
"embed",
|
|
52
|
+
"svg",
|
|
53
|
+
"math",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const ATTRIBUTES: Record<string, readonly string[]> = {
|
|
57
|
+
a: ["href", "title"],
|
|
58
|
+
img: ["src", "alt", "title"],
|
|
59
|
+
th: ["colspan", "rowspan", "align"],
|
|
60
|
+
td: ["colspan", "rowspan", "align"],
|
|
61
|
+
ol: ["start"],
|
|
62
|
+
code: ["class"],
|
|
63
|
+
input: ["type", "checked", "disabled"],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const ALLOWED_ATTR = [...new Set(Object.values(ATTRIBUTES).flat())];
|
|
67
|
+
|
|
68
|
+
const CODE_CLASS = /^language-[\w-]+$/;
|
|
69
|
+
const DIGITS = /^\d{1,4}$/;
|
|
70
|
+
const ALIGN = /^(left|right|center)$/i;
|
|
71
|
+
|
|
72
|
+
export function attributeAllowed(tag: string, name: string, value: string): boolean {
|
|
73
|
+
if (!Object.hasOwn(ATTRIBUTES, tag) || !ATTRIBUTES[tag].includes(name)) return false;
|
|
74
|
+
switch (name) {
|
|
75
|
+
case "class":
|
|
76
|
+
return CODE_CLASS.test(value);
|
|
77
|
+
case "type":
|
|
78
|
+
return value.toLowerCase() === "checkbox";
|
|
79
|
+
case "colspan":
|
|
80
|
+
case "rowspan":
|
|
81
|
+
case "start":
|
|
82
|
+
return DIGITS.test(value);
|
|
83
|
+
case "align":
|
|
84
|
+
return ALIGN.test(value);
|
|
85
|
+
default:
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const RASTER_DATA_URI = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/i;
|
|
91
|
+
|
|
92
|
+
// Express matches routes case-insensitively, so /API/... reaches the same handlers.
|
|
93
|
+
const ADMIN_API_PATH = /^\/api(\/|$)/i;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A remote image is a tracking channel: agent output can encode data in its URL. Only an image
|
|
97
|
+
* served by the hub's static files or embedded in the text is kept; the admin API is not an
|
|
98
|
+
* image host. The WHATWG parser decides what a browser would request, so tab, backslash, `//`
|
|
99
|
+
* and dot-segment tricks resolve to their real origin and path.
|
|
100
|
+
*/
|
|
101
|
+
export function isSafeImageSrc(src: string, hubOrigin: string): boolean {
|
|
102
|
+
if (RASTER_DATA_URI.test(src)) return true;
|
|
103
|
+
if (!src.trim()) return false;
|
|
104
|
+
let url: URL;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(src, hubOrigin);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
(url.protocol === "https:" || url.protocol === "http:") &&
|
|
112
|
+
url.origin === hubOrigin &&
|
|
113
|
+
!ADMIN_API_PATH.test(url.pathname)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import DOMPurify from "dompurify";
|
|
3
|
+
import { externalHref } from "./link-policy";
|
|
4
|
+
import {
|
|
5
|
+
ALLOWED_ATTR,
|
|
6
|
+
ALLOWED_TAGS,
|
|
7
|
+
FORBIDDEN_TAGS,
|
|
8
|
+
attributeAllowed,
|
|
9
|
+
isSafeImageSrc,
|
|
10
|
+
} from "./markdown-policy";
|
|
11
|
+
|
|
12
|
+
// DOMPurify only exists with a DOM; SSR and unit tests import this module without one.
|
|
13
|
+
if (DOMPurify.isSupported) {
|
|
14
|
+
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
15
|
+
const tag = node.nodeName.toLowerCase();
|
|
16
|
+
// The tag allowlist is global; which attributes an element may keep is decided per element.
|
|
17
|
+
for (const { name, value } of Array.from(node.attributes))
|
|
18
|
+
if (!attributeAllowed(tag, name, value)) node.removeAttribute(name);
|
|
19
|
+
if (tag === "img" && !isSafeImageSrc(node.getAttribute("src") ?? "", location.origin)) {
|
|
20
|
+
node.replaceWith(document.createTextNode(node.getAttribute("alt") ?? ""));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (tag === "input") {
|
|
24
|
+
if (node.getAttribute("type") === null) node.remove();
|
|
25
|
+
else node.setAttribute("disabled", "");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (tag !== "a") return;
|
|
29
|
+
const href = node.getAttribute("href");
|
|
30
|
+
const safe = href === null ? null : externalHref(href, location.origin);
|
|
31
|
+
if (safe) {
|
|
32
|
+
node.setAttribute("href", safe);
|
|
33
|
+
node.setAttribute("target", "_blank");
|
|
34
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
35
|
+
} else {
|
|
36
|
+
node.removeAttribute("href");
|
|
37
|
+
node.removeAttribute("target");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SANITIZE = {
|
|
43
|
+
ALLOWED_TAGS,
|
|
44
|
+
ALLOWED_ATTR,
|
|
45
|
+
FORBID_TAGS: FORBIDDEN_TAGS,
|
|
46
|
+
ALLOW_DATA_ATTR: false,
|
|
47
|
+
ALLOW_ARIA_ATTR: false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Transcripts re-render on every stream frame; cache rendered blocks by their source text.
|
|
51
|
+
const cache = new Map<string, string>();
|
|
52
|
+
export function markdown(text: string) {
|
|
53
|
+
let html = cache.get(text);
|
|
54
|
+
if (html === undefined) {
|
|
55
|
+
html = DOMPurify.sanitize(marked.parse(text, { async: false }) as string, SANITIZE);
|
|
56
|
+
if (cache.size > 400) cache.delete(cache.keys().next().value!);
|
|
57
|
+
cache.set(text, html);
|
|
58
|
+
}
|
|
59
|
+
return html;
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CancelResult = "cancelled" | "issued" | "failed";
|
|
2
|
+
|
|
3
|
+
export const CANCEL_TIMEOUT_MS = 4000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cancel the live code first, but never let a failing or hanging request keep the wizard open:
|
|
7
|
+
* an unconfirmed code simply expires on the hub. Only a code the device already redeemed
|
|
8
|
+
* keeps the wizard open, so the operator sees it and can revoke it explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export async function closeWizard(opts: {
|
|
11
|
+
needsCancel: boolean;
|
|
12
|
+
cancel: () => Promise<CancelResult>;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
}): Promise<{ closed: boolean; confirmed: boolean }> {
|
|
15
|
+
if (!opts.needsCancel) return { closed: true, confirmed: true };
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
17
|
+
const result = await Promise.race<CancelResult>([
|
|
18
|
+
opts.cancel().catch((): CancelResult => "failed"),
|
|
19
|
+
new Promise<CancelResult>((done) => (timer = setTimeout(() => done("failed"), opts.timeoutMs))),
|
|
20
|
+
]);
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
return { closed: result !== "issued", confirmed: result === "cancelled" };
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Pair links stay in RAM and may only refer to the browser's own hub. */
|
|
2
|
+
export type PairHandoff = { code: string; origin: string } | { error: string };
|
|
3
|
+
|
|
4
|
+
export function readPairHandoff(url: URL): PairHandoff | undefined {
|
|
5
|
+
if (!/^\/pair\/?$/.test(url.pathname)) return undefined;
|
|
6
|
+
const params = url.searchParams;
|
|
7
|
+
if (!params.has("code") && !params.has("origin") && !params.has("v")) return undefined;
|
|
8
|
+
const code = params.get("code");
|
|
9
|
+
if (
|
|
10
|
+
url.username ||
|
|
11
|
+
url.password ||
|
|
12
|
+
url.hash ||
|
|
13
|
+
params.getAll("code").length !== 1 ||
|
|
14
|
+
!code ||
|
|
15
|
+
!/^[A-Z2-7]{8}$/.test(code) ||
|
|
16
|
+
params.getAll("v").length !== 1 ||
|
|
17
|
+
params.get("v") !== "1"
|
|
18
|
+
) {
|
|
19
|
+
return { error: "Invalid pairing link. Use a complete version 1 link from the hub wizard." };
|
|
20
|
+
}
|
|
21
|
+
if (params.has("origin")) {
|
|
22
|
+
try {
|
|
23
|
+
const origin = new URL(params.get("origin")!);
|
|
24
|
+
if (
|
|
25
|
+
params.getAll("origin").length !== 1 ||
|
|
26
|
+
origin.origin !== url.origin ||
|
|
27
|
+
origin.pathname !== "/" ||
|
|
28
|
+
origin.username ||
|
|
29
|
+
origin.password ||
|
|
30
|
+
origin.search ||
|
|
31
|
+
origin.hash
|
|
32
|
+
)
|
|
33
|
+
throw Error("origin mismatch");
|
|
34
|
+
} catch {
|
|
35
|
+
return {
|
|
36
|
+
error: "This pairing link does not belong to this hub. Open the original hub link instead.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { code, origin: url.origin };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The scrubbed /pair URL would still open the wizard, and mint a fresh code, on reload.
|
|
45
|
+
* Returns the path to replace it with, or undefined when the URL is not a pair URL.
|
|
46
|
+
*/
|
|
47
|
+
export const settledPairPath = (url: URL): string | undefined =>
|
|
48
|
+
/^\/pair\/?$/.test(url.pathname) ? "/" : undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A bare /pair URL has no handoff to resume: re-opening the wizard there would
|
|
52
|
+
* mint a fresh pairing code on every reload. Go home instead — the admin
|
|
53
|
+
* sidebar button remains the only way to start a pairing without a link.
|
|
54
|
+
*/
|
|
55
|
+
export const barePairHome = (url: URL): string | undefined =>
|
|
56
|
+
/^\/pair\/?$/.test(url.pathname) && readPairHandoff(url) === undefined ? "/" : undefined;
|