privateer-agent 0.3.6 → 0.4.0
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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension management for linked terminals.
|
|
3
|
+
*
|
|
4
|
+
* A UI-agnostic wrapper over Pi's PackageManager so the app (over the relay) can
|
|
5
|
+
* see which Pi extensions THIS terminal has installed and add/remove them — the
|
|
6
|
+
* same way the model picker lets the app switch models. Both the dev REPL
|
|
7
|
+
* (src/cli/chat.ts) and the shipped TUI (extensions/privateer-gate.ts) build one
|
|
8
|
+
* of these and route the extensions_* relay frames through it.
|
|
9
|
+
*
|
|
10
|
+
* Only the user's OWN packages surface here. The Privateer moat (privateer-*,
|
|
11
|
+
* pi-privacy, rpiv-web-tools, …) is installed by bin/privateer-tui as shim .ts
|
|
12
|
+
* files in the agent dir's extensions/ folder — auto-discovered, NOT recorded in
|
|
13
|
+
* settings.json "packages". listConfiguredPackages() reads only "packages", so the
|
|
14
|
+
* moat is naturally excluded. We keep a RESERVED name guard as defence in depth in
|
|
15
|
+
* case a user ever hand-adds one of our package names.
|
|
16
|
+
*
|
|
17
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
18
|
+
* frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
|
|
19
|
+
* the TUI creates a fresh one — both read the same ~/.privateer/agent/settings.json).
|
|
20
|
+
*/
|
|
21
|
+
import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { ProgressEvent, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
// One installed extension as surfaced to the app. NON-PII: a package source
|
|
25
|
+
// (npm:/git: spec) plus its scope — no cwd, no absolute paths beyond what Pi
|
|
26
|
+
// already resolved. `installed` reflects whether the package is downloaded on disk
|
|
27
|
+
// yet (a freshly-added one persists to settings before its install completes).
|
|
28
|
+
export interface InstalledExtension {
|
|
29
|
+
source: string;
|
|
30
|
+
scope: "user" | "project";
|
|
31
|
+
filtered: boolean;
|
|
32
|
+
installed: boolean;
|
|
33
|
+
installedPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExtensionsControl {
|
|
37
|
+
// The user's own configured packages (moat excluded). Cheap — reads settings.
|
|
38
|
+
listInstalled(): InstalledExtension[];
|
|
39
|
+
// Persist + download an npm:/git:/path package to USER settings. Returns ok:false
|
|
40
|
+
// with Pi's own message on a bad spec or a failed npm/git fetch.
|
|
41
|
+
add(source: string): Promise<{ ok: boolean; message?: string }>;
|
|
42
|
+
// Remove from USER settings + prune the cache. ok:false when nothing matched.
|
|
43
|
+
remove(source: string): Promise<{ ok: boolean; message?: string }>;
|
|
44
|
+
// Progress callback for the current add/remove (install/clone/pull steps), so the
|
|
45
|
+
// caller can relay a busy indicator. Pass undefined to clear.
|
|
46
|
+
setProgress(cb: ((ev: ProgressEvent) => void) | undefined): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Package names we never manage from the app: the Privateer moat + adopted packs
|
|
50
|
+
// installed as shims by the launcher. A guard only — listConfiguredPackages()
|
|
51
|
+
// already omits them since they aren't settings "packages".
|
|
52
|
+
const RESERVED = new Set([
|
|
53
|
+
"privateer-brand",
|
|
54
|
+
"privateer-context",
|
|
55
|
+
"privateer-gate",
|
|
56
|
+
"privateer-account",
|
|
57
|
+
"privateer-posture",
|
|
58
|
+
"privateer-tools",
|
|
59
|
+
"privateer-privacy",
|
|
60
|
+
"pi-privacy",
|
|
61
|
+
"pi-web-access",
|
|
62
|
+
"rpiv-web-tools",
|
|
63
|
+
"@juicesharp/rpiv-web-tools",
|
|
64
|
+
"pi-mcp-adapter",
|
|
65
|
+
"pi-hypa",
|
|
66
|
+
"@hypabolic/pi-hypa",
|
|
67
|
+
"pi-subagents",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// The bare package name inside a source spec, for the RESERVED check. Strips the
|
|
71
|
+
// npm:/git: scheme and any @version / #ref suffix; leaves scoped names intact.
|
|
72
|
+
function packageName(source: string): string {
|
|
73
|
+
let s = source.trim();
|
|
74
|
+
const scheme = s.indexOf(":");
|
|
75
|
+
if (scheme > 0 && /^(npm|git)$/i.test(s.slice(0, scheme))) s = s.slice(scheme + 1);
|
|
76
|
+
// Drop a trailing @version (but not the leading @ of a scope) and a git #ref.
|
|
77
|
+
s = s.replace(/#.*$/, "");
|
|
78
|
+
const at = s.lastIndexOf("@");
|
|
79
|
+
if (at > 0) s = s.slice(0, at);
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeExtensionsControl(opts: {
|
|
84
|
+
cwd: string;
|
|
85
|
+
agentDir: string;
|
|
86
|
+
settingsManager: SettingsManager;
|
|
87
|
+
}): ExtensionsControl {
|
|
88
|
+
const pm = new DefaultPackageManager({
|
|
89
|
+
cwd: opts.cwd,
|
|
90
|
+
agentDir: opts.agentDir,
|
|
91
|
+
settingsManager: opts.settingsManager,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
listInstalled(): InstalledExtension[] {
|
|
96
|
+
let configured: Array<{ source: string; scope: string; filtered: boolean; installedPath?: string }> = [];
|
|
97
|
+
try {
|
|
98
|
+
configured = pm.listConfiguredPackages() as typeof configured;
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return configured
|
|
103
|
+
.filter((p) => !RESERVED.has(packageName(p.source)))
|
|
104
|
+
.map((p) => ({
|
|
105
|
+
source: p.source,
|
|
106
|
+
scope: p.scope === "project" ? "project" : "user",
|
|
107
|
+
filtered: !!p.filtered,
|
|
108
|
+
installed: !!p.installedPath,
|
|
109
|
+
installedPath: p.installedPath,
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async add(source: string): Promise<{ ok: boolean; message?: string }> {
|
|
114
|
+
const src = source.trim();
|
|
115
|
+
if (!src) return { ok: false, message: "No package specified." };
|
|
116
|
+
if (RESERVED.has(packageName(src))) return { ok: false, message: "That extension is managed by Privateer." };
|
|
117
|
+
try {
|
|
118
|
+
await pm.installAndPersist(src, { local: false });
|
|
119
|
+
return { ok: true };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { ok: false, message: (e as Error).message };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async remove(source: string): Promise<{ ok: boolean; message?: string }> {
|
|
126
|
+
const src = source.trim();
|
|
127
|
+
if (!src) return { ok: false, message: "No package specified." };
|
|
128
|
+
try {
|
|
129
|
+
const removed = await pm.removeAndPersist(src, { local: false });
|
|
130
|
+
return removed ? { ok: true } : { ok: false, message: "Not installed." };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, message: (e as Error).message };
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
setProgress(cb: ((ev: ProgressEvent) => void) | undefined): void {
|
|
137
|
+
pm.setProgressCallback(cb);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A live, app-drivable agent session spawned on demand by the daemon (task_spawn,
|
|
2
|
+
// mode:"live"). Unlike a headless task (daemon/index.ts runTask → restricted tools +
|
|
3
|
+
// bypass gate + outbox), this stands up a FULL interactive session behind its own relay
|
|
4
|
+
// terminal so the app can attach and drive it in real time: stream tokens, approve each
|
|
5
|
+
// tool, interrupt. It is the same wiring cli/chat.ts uses for `/remote-access`, factored
|
|
6
|
+
// so the daemon can create one without a TTY.
|
|
7
|
+
//
|
|
8
|
+
// SAFETY: the gate runs in "default" mode, so on a driven turn every gated tool relays to
|
|
9
|
+
// the app for allow/deny (bridge.remoteAsk) and fail-closes if the controller is gone —
|
|
10
|
+
// full tools are safe precisely because a human is watching and approving. localAsk denies
|
|
11
|
+
// (there is no terminal to prompt), and remote-unsafe tools (subagents) are blocked.
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import {
|
|
14
|
+
createAgentSessionServices,
|
|
15
|
+
createAgentSessionFromServices,
|
|
16
|
+
SessionManager,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { agentDir } from "../config/paths.ts";
|
|
19
|
+
import { agentVersion } from "../config/version.ts";
|
|
20
|
+
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
|
+
import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
|
+
import { makePiPrivacyExtension } from "pi-privacy";
|
|
23
|
+
import { makeAccountProvider } from "../providers/account.ts";
|
|
24
|
+
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
25
|
+
import { RemoteBridge } from "./remoteBridge.ts";
|
|
26
|
+
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
27
|
+
|
|
28
|
+
export interface LiveTaskHandle {
|
|
29
|
+
termId: string;
|
|
30
|
+
label: string;
|
|
31
|
+
stop: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LiveTaskDeps {
|
|
35
|
+
defaultModel: string;
|
|
36
|
+
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
37
|
+
log: (msg: string) => void;
|
|
38
|
+
onClosed: (termId: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// How long to keep a spawned session alive with NO controller ever attaching, and the
|
|
42
|
+
// hard ceiling on any one session's lifetime (a driven session left open is reaped so an
|
|
43
|
+
// abandoned spawn can't run the account meter or hold resources forever).
|
|
44
|
+
const ATTACH_GRACE_MS = 180_000; // 3 min to attach after spawn
|
|
45
|
+
const MAX_LIFETIME_MS = 30 * 60_000; // 30 min absolute cap
|
|
46
|
+
|
|
47
|
+
export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps): Promise<LiveTaskHandle> {
|
|
48
|
+
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
49
|
+
const modelSpec = spec.model && spec.model.trim() ? spec.model : deps.defaultModel;
|
|
50
|
+
const title = spec.title && spec.title.trim() ? spec.title.trim() : "";
|
|
51
|
+
const termId = `task-${randomUUID()}`;
|
|
52
|
+
const label = title ? `Task: ${title}`.slice(0, 60) : "Privateer Task";
|
|
53
|
+
|
|
54
|
+
let relay: RelayClient | undefined;
|
|
55
|
+
let session: any;
|
|
56
|
+
let turnActive = false;
|
|
57
|
+
let attached = false;
|
|
58
|
+
let initialPromptSent = false;
|
|
59
|
+
let stopped = false;
|
|
60
|
+
let spawnedAccount = false;
|
|
61
|
+
let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
|
|
62
|
+
|
|
63
|
+
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
|
|
66
|
+
const stop = async (): Promise<void> => {
|
|
67
|
+
if (stopped) return;
|
|
68
|
+
stopped = true;
|
|
69
|
+
if (attachTimer) clearTimeout(attachTimer);
|
|
70
|
+
if (lifeTimer) clearTimeout(lifeTimer);
|
|
71
|
+
try { relay?.stop(); } catch { /* already stopped */ }
|
|
72
|
+
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
73
|
+
// app's Linked Devices; the daemon's own child session stays alive. Best-effort.
|
|
74
|
+
if (spawnedAccount) {
|
|
75
|
+
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
76
|
+
try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
|
|
77
|
+
}
|
|
78
|
+
deps.onClosed(termId);
|
|
79
|
+
deps.log(`live task ${termId} closed`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const runTurn = async (text: string): Promise<void> => {
|
|
83
|
+
if (turnActive || stopped) return;
|
|
84
|
+
turnActive = true;
|
|
85
|
+
try {
|
|
86
|
+
await session.prompt(text);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
deps.log(`live task ${termId} turn error: ${(e as Error).message}`);
|
|
89
|
+
} finally {
|
|
90
|
+
turnActive = false;
|
|
91
|
+
bridge.settleTurn();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const bridge = new RemoteBridge({
|
|
96
|
+
onPrompt: (text) => void runTurn(text),
|
|
97
|
+
onInterrupt: () => void session?.abort?.(),
|
|
98
|
+
// Slash commands from the app composer fall through to the turn loop (Pi executes
|
|
99
|
+
// extension/skill commands via prompt). No local dispatcher here — this is headless.
|
|
100
|
+
onCommand: (text) => void runTurn(text),
|
|
101
|
+
onControllerAttached: () => {
|
|
102
|
+
attached = true;
|
|
103
|
+
if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
|
|
104
|
+
relay?.sendSnapshot([]);
|
|
105
|
+
relay?.sendContext({ model: modelSpec, version: agentVersion() });
|
|
106
|
+
relay?.sendCommands([]);
|
|
107
|
+
// Deliver the spawn's initial prompt exactly once, THROUGH the bridge's own prompt
|
|
108
|
+
// path so it counts as a driven turn (remote=true → tools relay to the app).
|
|
109
|
+
if (!initialPromptSent && spec.prompt && spec.prompt.trim()) {
|
|
110
|
+
initialPromptSent = true;
|
|
111
|
+
bridge.callbacks.onPrompt(spec.prompt);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
onTerminate: () => void stop(),
|
|
115
|
+
onStatus: (t) => deps.log(`live task ${termId}: ${t}`),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const gate: GateController = {
|
|
119
|
+
getMode: () => "default",
|
|
120
|
+
setMode: () => {},
|
|
121
|
+
allowlist: [],
|
|
122
|
+
allowedOutsideRoots: [],
|
|
123
|
+
cwd,
|
|
124
|
+
confineToCwd: true,
|
|
125
|
+
// No terminal to ask — a LOCAL turn can't happen here, but fail closed if one ever
|
|
126
|
+
// reaches this path.
|
|
127
|
+
async localAsk() {
|
|
128
|
+
return "deny";
|
|
129
|
+
},
|
|
130
|
+
getRemote: bridge.getRemote,
|
|
131
|
+
getNoQuarter: bridge.getNoQuarter,
|
|
132
|
+
remoteAsk: bridge.remoteAsk,
|
|
133
|
+
blockedWhenRemote: isRemoteUnsafeTool,
|
|
134
|
+
onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const services = await createAgentSessionServices({
|
|
138
|
+
cwd,
|
|
139
|
+
agentDir: agentDir(),
|
|
140
|
+
resourceLoaderOptions: {
|
|
141
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
servicesRef = services as any;
|
|
145
|
+
|
|
146
|
+
const { provider, modelId } = deps.parseSpec(modelSpec);
|
|
147
|
+
if (provider === "privateer") {
|
|
148
|
+
try {
|
|
149
|
+
const creds = await spawnAccountCredentials();
|
|
150
|
+
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
151
|
+
spawnedAccount = true;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// From here on any failure MUST tear down (stop() revokes the account child session we
|
|
158
|
+
// just spawned + closes the relay), or a throw would leak an orphaned account "device"
|
|
159
|
+
// until its token TTL. Everything post-account-spawn runs under one guard.
|
|
160
|
+
try {
|
|
161
|
+
const model = (services.modelRegistry as any).find(provider, modelId);
|
|
162
|
+
if (!model) {
|
|
163
|
+
throw new Error(`model ${provider}/${modelId} not found`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
({ session } = await createAgentSessionFromServices({
|
|
167
|
+
services,
|
|
168
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
169
|
+
model,
|
|
170
|
+
// No `tools` restriction: a live session gets Pi's full toolset, made safe by the
|
|
171
|
+
// per-tool relay-to-app approval above.
|
|
172
|
+
} as any));
|
|
173
|
+
|
|
174
|
+
// Relay the extension mid-turn UI (select/confirm/input) to the app when driven, so an
|
|
175
|
+
// extension asking a question doesn't silently cancel. Mirrors cli/chat.ts's uiContext.
|
|
176
|
+
const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
|
|
177
|
+
const uiContext = {
|
|
178
|
+
async select(t: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
179
|
+
if (!options.length) return undefined;
|
|
180
|
+
if (!driven()) return undefined;
|
|
181
|
+
const choice = await bridge.selectRemote({ title: t, options: options.map((o) => ({ value: o, label: o })) }, opts?.signal);
|
|
182
|
+
return choice ?? undefined;
|
|
183
|
+
},
|
|
184
|
+
async confirm(t: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
|
|
185
|
+
if (!driven()) return false;
|
|
186
|
+
const choice = await bridge.selectRemote({ title: t || message, options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }] }, opts?.signal);
|
|
187
|
+
return choice === "yes";
|
|
188
|
+
},
|
|
189
|
+
async input(t: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
190
|
+
if (!driven()) return undefined;
|
|
191
|
+
const value = await bridge.inputRemote({ title: t, placeholder }, opts?.signal);
|
|
192
|
+
return value ?? undefined;
|
|
193
|
+
},
|
|
194
|
+
notify(message: string): void {
|
|
195
|
+
if (driven()) bridge.sendNotice(message);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
await (session as any).bindExtensions({ uiContext });
|
|
199
|
+
|
|
200
|
+
const adapter = createEngineEventAdapter();
|
|
201
|
+
session.subscribe((ev: any) => {
|
|
202
|
+
for (const ee of adapter.toEngineEvents(ev)) bridge.forwardEvent(ee);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
relay = new RelayClient(bridge.callbacks, { termId, label });
|
|
206
|
+
bridge.attachRelay(relay);
|
|
207
|
+
await relay.start();
|
|
208
|
+
|
|
209
|
+
// Reap if nobody ever attaches, and cap the absolute lifetime regardless.
|
|
210
|
+
attachTimer = setTimeout(() => { if (!attached) void stop(); }, ATTACH_GRACE_MS);
|
|
211
|
+
lifeTimer = setTimeout(() => void stop(), MAX_LIFETIME_MS);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
await stop(); // revoke the account child session + close the relay before propagating
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { termId, label, stop };
|
|
218
|
+
}
|