privateer-agent 0.6.4 → 0.6.7

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.
@@ -15,7 +15,7 @@ import {
15
15
  hasCredentials,
16
16
  runDeviceLogin,
17
17
  authedFetch,
18
- spawnAccountCredentials,
18
+ acquireAccountCredential,
19
19
  refreshAccountCredentials,
20
20
  notifySignedIn,
21
21
  } from "../auth/privateer.ts";
@@ -169,7 +169,7 @@ export const privateerOAuthProvider = {
169
169
  }
170
170
  }
171
171
  if (cb.signal?.aborted) throw new Error("Login cancelled");
172
- const creds = await spawnAccountCredentials();
172
+ const creds = await acquireAccountCredential();
173
173
  // Seed Pi's saved model default to the account channel, so the next launch resolves
174
174
  // to a billable subscription model instead of falling through to a keyless built-in
175
175
  // (the "No API key found for openrouter" trap). No-op if the user already has a
@@ -184,8 +184,10 @@ export const privateerOAuthProvider = {
184
184
  try {
185
185
  return await refreshAccountCredentials(creds.refresh);
186
186
  } catch {
187
- // child token expired/reused → spawn a fresh one from the parent login.
188
- return spawnAccountCredentials();
187
+ // Child token expired/reused → get another. acquire (not spawn) so a terminal
188
+ // that already holds the device's last session slot can reclaim an orphan
189
+ // instead of being refused a fresh one mid-session.
190
+ return acquireAccountCredential();
189
191
  }
190
192
  },
191
193
  getApiKey(creds: { access: string }): string {
@@ -256,6 +258,7 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
256
258
  export function makeAccountProvider() {
257
259
  return (pi: {
258
260
  registerProvider?: (name: string, config: unknown) => void;
261
+ on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
259
262
  }): void => {
260
263
  if (typeof pi.registerProvider !== "function") return;
261
264
  const register = (ids: string[]): void =>
@@ -274,5 +277,57 @@ export function makeAccountProvider() {
274
277
  .catch(() => {
275
278
  /* keep the fallback model */
276
279
  });
280
+
281
+ // Seed the account channel's credential at launch. Nothing else does this in the
282
+ // TUI: Pi only obtains an OAuth credential by running /login, and our shutdown
283
+ // hook deliberately REVOKES the account session and deletes its persisted
284
+ // auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
285
+ // signed-in user who quits and relaunches lands on privateer/* with no key at
286
+ // all, and the first prompt dead-ends on "No API key found for privateer." — even
287
+ // though the banner says "connected". The REPL (cli/chat.ts) and the daemon
288
+ // already spawn one at startup; this gives the TUI the same seed.
289
+ pi.on?.("session_start", (_e, ctx) => void ensureAccountCredential(ctx));
277
290
  };
278
291
  }
292
+
293
+ // One spawn per PROCESS. session_start also fires for new/resume/fork/reload — all of
294
+ // which keep this process (and its account session) alive — so re-spawning there would
295
+ // leak a device row per event. A fresh process always spawns: a run that crashed
296
+ // without its shutdown hook can leave a REVOKED credential persisted in auth.json with
297
+ // a still-valid-looking `expires`, which Pi would happily reuse and 401 on.
298
+ //
299
+ // The flag lives on globalThis, not in module scope, because jiti gives each extension
300
+ // that imports this file its OWN module instance (see the note in auth/privateer.ts):
301
+ // privateer-account and privateer-brand — which hot-registers the provider on /signin —
302
+ // would otherwise hold separate flags and each spawn a session.
303
+ const SEEDED = Symbol.for("privateer.accountCredentialSeeded");
304
+ type SeedFlag = { [SEEDED]?: boolean };
305
+
306
+ // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
307
+ // path privateer-brand uses to DROP the credential on sign-out).
308
+ type SeedContext = {
309
+ modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
310
+ hasUI?: boolean;
311
+ ui?: { notify?: (message: string, level: string) => void };
312
+ };
313
+
314
+ async function ensureAccountCredential(ctx: unknown): Promise<void> {
315
+ const flag = globalThis as SeedFlag;
316
+ if (flag[SEEDED] || !hasCredentials()) return;
317
+ flag[SEEDED] = true;
318
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
319
+ if (typeof store?.set !== "function") return;
320
+ try {
321
+ const creds = await acquireAccountCredential();
322
+ store.set("privateer", { type: "oauth", ...creds });
323
+ } catch (e) {
324
+ // The account channel is NOT armed: a dead machine login (401 → credentials cleared
325
+ // + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
326
+ // banner still reads "connected" (it only knows about the local credentials file),
327
+ // so staying silent leaves the user to discover it as a bare "No API key found for
328
+ // privateer" on their first prompt. Cleared so a later attempt can retry.
329
+ flag[SEEDED] = false;
330
+ const c = ctx as SeedContext;
331
+ if (c?.hasUI) c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
332
+ }
333
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * MCP connector management for the app — the sibling of channelsControl.ts, but for
3
+ * MCP server config rather than messaging channels. It is what lets the phone/web
4
+ * client add, toggle, and remove MCP connectors on a Node HOST it drives over the
5
+ * relay (the daemon today; an interactive terminal by the same shape).
6
+ *
7
+ * The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
8
+ * stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
9
+ * config here, on a host that executes it. This control owns that config.
10
+ *
11
+ * SAME FILE MODEL AS THE DESKTOP (treeview/desktop/src/main/mcpService.ts): the
12
+ * source of truth is `${agentDir}/mcp-desktop.json` — every server with an `enabled`
13
+ * flag — and from it we PROJECT the standard `${agentDir}/mcp.json` (enabled servers
14
+ * only, `{mcpServers:{}}` shape) that pi-mcp-adapter reads. Sharing those two files
15
+ * means a machine has ONE coherent MCP config whether it was edited from the desktop
16
+ * over IPC or from the phone over the relay.
17
+ *
18
+ * SECRETS: MCP env values are credentials (GITHUB_PERSONAL_ACCESS_TOKEN, …). Over the
19
+ * untrusted relay they are WRITE-ONLY, exactly like channel bot tokens: list() NEVER
20
+ * returns an env VALUE — only which env keys exist (`envKeys`) and which are non-empty
21
+ * (`secretsSet`), by name. save() persists whatever env VALUES it is handed in
22
+ * `draft.env`; the seal/open of those values in transit is the caller's job (the
23
+ * daemon opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
24
+ * this module only ever deals in the plaintext files it already owns.
25
+ *
26
+ * Framework-agnostic: nothing here imports React or the relay. The caller owns the
27
+ * frame plumbing and the sealed-secret open.
28
+ */
29
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
30
+ import { join, dirname } from "node:path";
31
+ import { agentDir } from "../config/paths.ts";
32
+
33
+ export type McpTransport = "stdio" | "http";
34
+
35
+ // One server as stored in the source file (mcp-desktop.json). Mirrors the desktop's
36
+ // SourceEntry: the standard fields the adapter needs plus our `enabled` flag.
37
+ interface SourceEntry {
38
+ transport?: McpTransport;
39
+ command?: string;
40
+ args?: string[];
41
+ env?: Record<string, string>;
42
+ url?: string;
43
+ oauth?: boolean;
44
+ enabled?: boolean;
45
+ }
46
+ interface SourceFile {
47
+ servers: Record<string, SourceEntry>;
48
+ }
49
+
50
+ // Non-secret projection of one server, sent to the app. No env VALUES, ever — only
51
+ // which env keys exist and which are set (`secretsSet`). `host` is surfaced for the
52
+ // app's privacy badge ("Sends data to <host>" for http; stdio runs locally).
53
+ export interface RemoteMcpServer {
54
+ name: string;
55
+ transport: McpTransport;
56
+ enabled: boolean;
57
+ command?: string; // stdio: the launch binary (not a secret — e.g. "npx")
58
+ argsPreview?: string; // stdio: args joined, for a one-line summary
59
+ url?: string; // http: the endpoint (not a secret; the vendor host)
60
+ host?: string; // http: parsed host for the privacy badge
61
+ oauth: boolean; // http servers negotiate OAuth; stdio never does
62
+ envKeys: string[]; // env var NAMES only (e.g. ["GITHUB_PERSONAL_ACCESS_TOKEN"])
63
+ secretsSet: string[]; // subset of envKeys whose value is non-empty — names only
64
+ }
65
+
66
+ // An app-submitted edit. Non-secret fields REPLACE when present; `env` maps a var
67
+ // name → its (already-opened) value, and only present, non-empty values overwrite —
68
+ // an omitted key keeps the existing value (so a re-save without re-typing the token
69
+ // preserves it, matching the channels-manager rule).
70
+ export interface McpDraft {
71
+ name: string;
72
+ transport?: McpTransport;
73
+ command?: string;
74
+ args?: string[];
75
+ url?: string;
76
+ oauth?: boolean;
77
+ env?: Record<string, string>;
78
+ }
79
+
80
+ export interface McpControl {
81
+ // Every managed server, non-secret projection. Enabled or not — the app shows
82
+ // disabled connectors so they can be toggled back on.
83
+ list(): RemoteMcpServer[];
84
+ // Create or edit a server. Validates transport ⟷ required field (stdio→command,
85
+ // http→url). Returns a one-line result. Re-projects mcp.json on success.
86
+ save(draft: McpDraft): { ok: boolean; message?: string };
87
+ // Enable/disable a server (re-projects). ok:false when the name is unknown.
88
+ setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string };
89
+ // Delete a server entirely (re-projects). ok:false when nothing was configured.
90
+ remove(name: string): { ok: boolean; message?: string };
91
+ }
92
+
93
+ const TRANSPORTS: readonly McpTransport[] = ["stdio", "http"];
94
+ function isTransport(v: unknown): v is McpTransport {
95
+ return typeof v === "string" && TRANSPORTS.includes(v as McpTransport);
96
+ }
97
+
98
+ function hostOf(url: string): string | undefined {
99
+ try {
100
+ return new URL(url).host || undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function cleanArgs(v: unknown): string[] | undefined {
107
+ if (!Array.isArray(v)) return undefined;
108
+ return v.map((x) => String(x ?? "")).filter((s) => s.length > 0);
109
+ }
110
+
111
+ export function makeMcpControl(opts?: {
112
+ // Override the source/projection dir (tests). Defaults to the shared agent dir, so
113
+ // this control and the desktop's mcpService edit the SAME two files.
114
+ dir?: () => string;
115
+ }): McpControl {
116
+ const dir = opts?.dir ?? agentDir;
117
+ const sourcePath = () => join(dir(), "mcp-desktop.json");
118
+ const projectionPath = () => join(dir(), "mcp.json");
119
+
120
+ function readSource(): SourceFile {
121
+ // Seed from an existing standard mcp.json on first run (a machine that already
122
+ // had connectors before this control existed), so nothing is silently dropped.
123
+ try {
124
+ const raw = JSON.parse(readFileSync(sourcePath(), "utf8"));
125
+ if (raw && typeof raw === "object" && raw.servers) return { servers: raw.servers };
126
+ } catch {
127
+ /* fall through to seed */
128
+ }
129
+ const servers: Record<string, SourceEntry> = {};
130
+ try {
131
+ const proj = JSON.parse(readFileSync(projectionPath(), "utf8"));
132
+ for (const [name, entry] of Object.entries(proj?.mcpServers ?? {})) {
133
+ servers[name] = { ...(entry as SourceEntry), enabled: true };
134
+ }
135
+ } catch {
136
+ /* no prior config */
137
+ }
138
+ return { servers };
139
+ }
140
+
141
+ function writeSource(src: SourceFile): void {
142
+ mkdirSync(dirname(sourcePath()), { recursive: true });
143
+ writeFileSync(sourcePath(), JSON.stringify(src, null, 2) + "\n");
144
+ project(src);
145
+ }
146
+
147
+ // Project the enabled servers into the standard mcp.json the adapter reads. An
148
+ // entry with no explicit transport is treated as stdio if it has a command, http
149
+ // if it has a url — matching the adapter's own inference.
150
+ function project(src: SourceFile): void {
151
+ const mcpServers: Record<string, unknown> = {};
152
+ for (const [name, e] of Object.entries(src.servers)) {
153
+ if (e.enabled === false) continue;
154
+ const { enabled, ...std } = e;
155
+ mcpServers[name] = std;
156
+ }
157
+ mkdirSync(dirname(projectionPath()), { recursive: true });
158
+ writeFileSync(projectionPath(), JSON.stringify({ mcpServers }, null, 2) + "\n");
159
+ }
160
+
161
+ function toRemote(name: string, e: SourceEntry): RemoteMcpServer {
162
+ const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
163
+ const env = e.env ?? {};
164
+ const envKeys = Object.keys(env);
165
+ return {
166
+ name,
167
+ transport,
168
+ enabled: e.enabled !== false,
169
+ command: transport === "stdio" ? e.command : undefined,
170
+ argsPreview: transport === "stdio" && e.args?.length ? e.args.join(" ") : undefined,
171
+ url: transport === "http" ? e.url : undefined,
172
+ host: transport === "http" && e.url ? hostOf(e.url) : undefined,
173
+ // http servers negotiate OAuth; stdio never does (matches mcpService.list()).
174
+ oauth: transport === "http",
175
+ envKeys,
176
+ secretsSet: envKeys.filter((k) => String(env[k] ?? "").length > 0),
177
+ };
178
+ }
179
+
180
+ return {
181
+ list(): RemoteMcpServer[] {
182
+ const src = readSource();
183
+ return Object.entries(src.servers).map(([name, e]) => toRemote(name, e));
184
+ },
185
+
186
+ save(draft: McpDraft): { ok: boolean; message?: string } {
187
+ const name = String(draft?.name ?? "").trim();
188
+ if (!name) return { ok: false, message: "A connector needs a name." };
189
+ if (draft.transport !== undefined && !isTransport(draft.transport))
190
+ return { ok: false, message: "Unknown transport." };
191
+
192
+ const src = readSource();
193
+ const prev: SourceEntry = src.servers[name] ?? {};
194
+ const entry: SourceEntry = { ...prev };
195
+
196
+ const transport: McpTransport =
197
+ (draft.transport as McpTransport) ?? prev.transport ?? (draft.url || prev.url ? "http" : "stdio");
198
+ entry.transport = transport;
199
+
200
+ if (transport === "stdio") {
201
+ if (draft.command !== undefined) entry.command = String(draft.command).trim();
202
+ const args = cleanArgs(draft.args);
203
+ if (args !== undefined) entry.args = args;
204
+ // A stdio server can't reach a url and never does OAuth — clear stale fields.
205
+ delete entry.url;
206
+ delete entry.oauth;
207
+ if (!entry.command) return { ok: false, message: "A local (stdio) connector needs a command." };
208
+ } else {
209
+ if (draft.url !== undefined) entry.url = String(draft.url).trim();
210
+ if (draft.oauth !== undefined) entry.oauth = !!draft.oauth;
211
+ delete entry.command;
212
+ delete entry.args;
213
+ if (!entry.url) return { ok: false, message: "A remote (http) connector needs a URL." };
214
+ }
215
+
216
+ // Env/secrets: a present, non-empty value overwrites; an omitted key keeps the
217
+ // existing value (re-save without re-typing the token preserves it). An explicit
218
+ // empty string clears that key.
219
+ if (draft.env !== undefined) {
220
+ const merged: Record<string, string> = { ...(prev.env ?? {}) };
221
+ for (const [k, v] of Object.entries(draft.env)) {
222
+ const key = String(k).trim();
223
+ if (!key) continue;
224
+ const val = String(v ?? "");
225
+ if (val.length > 0) merged[key] = val;
226
+ else delete merged[key];
227
+ }
228
+ if (Object.keys(merged).length > 0) entry.env = merged;
229
+ else delete entry.env;
230
+ }
231
+
232
+ // A brand-new server comes up enabled; an edit preserves the prior flag.
233
+ entry.enabled = prev.enabled ?? true;
234
+
235
+ src.servers[name] = entry;
236
+ try {
237
+ writeSource(src);
238
+ } catch (e) {
239
+ return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
240
+ }
241
+ return { ok: true, message: `Saved "${name}".` };
242
+ },
243
+
244
+ setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string } {
245
+ const src = readSource();
246
+ if (!src.servers[name]) return { ok: false, message: "No such connector." };
247
+ src.servers[name].enabled = !!enabled;
248
+ try {
249
+ writeSource(src);
250
+ } catch (e) {
251
+ return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
252
+ }
253
+ return { ok: true, message: `${enabled ? "Enabled" : "Disabled"} "${name}".` };
254
+ },
255
+
256
+ remove(name: string): { ok: boolean; message?: string } {
257
+ const src = readSource();
258
+ if (!src.servers[name]) return { ok: false, message: "Not configured." };
259
+ delete src.servers[name];
260
+ try {
261
+ writeSource(src);
262
+ } catch (e) {
263
+ return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
264
+ }
265
+ return { ok: true, message: `Removed "${name}".` };
266
+ },
267
+ };
268
+ }
@@ -182,6 +182,22 @@ export interface RelayCallbacks {
182
182
  // The app asked to delete a platform's channel config, by platform name. Signed
183
183
  // (H2) — a forged removal is a DoS (the bot stops until re-added).
184
184
  onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
185
+ // The app opened the MCP connectors manager — reply with the current MCP config
186
+ // (a sendMcp frame). Owned by the daemon (the host that runs the adapter), so these
187
+ // only fire on its relay. Read-only, so unsigned.
188
+ onMcpList?: () => void;
189
+ // The app asked to create/edit an MCP connector. `draft` carries only NON-secret
190
+ // fields (name/transport/command/args/url/oauth). `sealedSecrets`, when present, is
191
+ // a base64 sealed-box the app sealed to THIS terminal's pinned pubkey, opening to
192
+ // `{ termId, env: {NAME: value} }` — the connector's credential env. `sig`+`ts`
193
+ // authenticate the WHOLE save with the pinned account key (same shape as
194
+ // channels_save), so a hostile relay can neither forge a token nor inject a command.
195
+ onMcpSave?: (draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number) => void;
196
+ // The app asked to enable/disable a connector by name. Signed (H2) — a forged toggle
197
+ // silently arms/disarms a tool surface. Idempotent (non-strict ts).
198
+ onMcpSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
199
+ // The app asked to delete a connector by name. Signed (H2) — a forged removal is a DoS.
200
+ onMcpRemove?: (name: string, sig?: string, ts?: number) => void;
185
201
  // The app opened the workflows manager — reply with the current workflow summaries
186
202
  // (a sendWorkflows frame). Owned by the daemon, so these only fire on its relay.
187
203
  onWorkflowsList?: () => void;
@@ -519,6 +535,27 @@ export class RelayClient {
519
535
  case "channels_remove":
520
536
  if (typeof frame.platform === "string") this.cb.onChannelsRemove?.(frame.platform, sig(frame), tsOf(frame));
521
537
  break;
538
+ case "mcp_list":
539
+ this.cb.onMcpList?.();
540
+ break;
541
+ case "mcp_save":
542
+ // The connector rides in `draft` (same slot as channels_save), untyped — the
543
+ // daemon strict-validates it via mcpControl.save after the signature check.
544
+ if (frame.draft && typeof frame.draft === "object") {
545
+ this.cb.onMcpSave?.(
546
+ frame.draft,
547
+ typeof frame.sealedSecrets === "string" ? frame.sealedSecrets : undefined,
548
+ sig(frame),
549
+ tsOf(frame),
550
+ );
551
+ }
552
+ break;
553
+ case "mcp_set_enabled":
554
+ if (typeof frame.name === "string") this.cb.onMcpSetEnabled?.(frame.name, frame.enabled === true, sig(frame), tsOf(frame));
555
+ break;
556
+ case "mcp_remove":
557
+ if (typeof frame.name === "string") this.cb.onMcpRemove?.(frame.name, sig(frame), tsOf(frame));
558
+ break;
522
559
  case "workflows_list":
523
560
  this.cb.onWorkflowsList?.();
524
561
  break;
@@ -875,6 +912,47 @@ export class RelayClient {
875
912
  });
876
913
  }
877
914
 
915
+ // Push the host's MCP connectors to the app's MCP manager. Sent on request and after
916
+ // each save/set_enabled/remove. Like sendChannels this is the user's OWN config echoed
917
+ // to their OWN app — but an env VALUE (a token) NEVER crosses this wire: only `envKeys`
918
+ // (names) and `secretsSet` (which of those are non-empty, by name) are sent, so a relay
919
+ // / server compromise can't lift a credential from this frame. List bounded like the
920
+ // other managers.
921
+ sendMcp(payload: {
922
+ items: {
923
+ name: string;
924
+ transport: string;
925
+ enabled: boolean;
926
+ command?: string;
927
+ argsPreview?: string;
928
+ url?: string;
929
+ host?: string;
930
+ oauth: boolean;
931
+ envKeys: string[];
932
+ secretsSet: string[];
933
+ }[];
934
+ busy?: boolean;
935
+ message?: string;
936
+ }): void {
937
+ this.rawSend({
938
+ type: "mcp",
939
+ items: payload.items.slice(0, 50).map((m) => ({
940
+ name: clip(String(m.name), 128),
941
+ transport: m.transport === "http" ? "http" : "stdio",
942
+ enabled: !!m.enabled,
943
+ command: m.command ? clip(m.command, 200) : undefined,
944
+ argsPreview: m.argsPreview ? clip(m.argsPreview, 500) : undefined,
945
+ url: m.url ? clip(m.url, 500) : undefined,
946
+ host: m.host ? clip(m.host, 200) : undefined,
947
+ oauth: !!m.oauth,
948
+ envKeys: (m.envKeys ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
949
+ secretsSet: (m.secretsSet ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
950
+ })),
951
+ busy: !!payload.busy,
952
+ message: payload.message ? clip(payload.message, 500) : undefined,
953
+ });
954
+ }
955
+
878
956
  // Push the daemon's saved workflows to the app's workflows manager as SUMMARIES (not
879
957
  // the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
880
958
  // and after each save/remove/run. The user's OWN config echoed to their OWN app, so
@@ -158,6 +158,13 @@ export class RemoteBridge {
158
158
  onChannelsList: () => {},
159
159
  onChannelsSave: () => {},
160
160
  onChannelsRemove: () => {},
161
+ // MCP connectors, like channels, are managed on the daemon (the host that runs the
162
+ // adapter) — the daemon's own relay handles mcp_*. These no-ops just satisfy Required;
163
+ // an interactive terminal manages MCP over IPC (desktop), never over this relay.
164
+ onMcpList: () => {},
165
+ onMcpSave: () => {},
166
+ onMcpSetEnabled: () => {},
167
+ onMcpRemove: () => {},
161
168
  // Workflows, like routines/channels, are daemon-owned — the daemon's own relay handles
162
169
  // workflows_*. These no-ops just satisfy Required; an interactive terminal never
163
170
  // surfaces workflows.
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { z } from "zod";
2
3
 
3
4
  // Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
@@ -79,6 +80,13 @@ export const RoutineFile = z.object({
79
80
  export type RoutineFile = z.infer<typeof RoutineFile>;
80
81
 
81
82
  // A time-ordered routine id minted once at creation.
83
+ //
84
+ // The random suffix is load-bearing, not decoration. This was `r-${Date.now()}` alone,
85
+ // so two routines created in the SAME MILLISECOND got the same id — and upsertRoutine
86
+ // keys on id, so the second silently overwrote the first. Creating routines in quick
87
+ // succession (an import, a scripted setup, a fast tap-tap in the app) could therefore
88
+ // lose one with no error anywhere. The timestamp prefix still sorts by creation order;
89
+ // the suffix just makes collisions vanishingly unlikely.
82
90
  export function newRoutineId(): string {
83
- return `r-${Date.now()}`;
91
+ return `r-${Date.now()}-${randomBytes(4).toString("hex")}`;
84
92
  }