orionfold-relay 0.15.3 → 0.15.5

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/dist/cli.js CHANGED
@@ -9347,6 +9347,26 @@ var init_helpers2 = __esm({
9347
9347
  // src/lib/chat/tools/project-tools.ts
9348
9348
  import { z as z9 } from "zod";
9349
9349
  import { eq as eq21, and as and10, count } from "drizzle-orm";
9350
+ async function findSimilarProjects(candidateName) {
9351
+ const candidateNameLower = candidateName.trim().toLowerCase();
9352
+ if (!candidateNameLower) return [];
9353
+ const existing = await db.select({
9354
+ id: projects.id,
9355
+ name: projects.name,
9356
+ description: projects.description
9357
+ }).from(projects);
9358
+ const matches = [];
9359
+ for (const row of existing) {
9360
+ if (row.name.trim().toLowerCase() === candidateNameLower) {
9361
+ matches.push({
9362
+ id: row.id,
9363
+ name: row.name,
9364
+ reason: `Same name: "${row.name}"`
9365
+ });
9366
+ }
9367
+ }
9368
+ return matches;
9369
+ }
9350
9370
  function projectTools(ctx) {
9351
9371
  return [
9352
9372
  defineTool(
@@ -9380,10 +9400,23 @@ function projectTools(ctx) {
9380
9400
  {
9381
9401
  name: z9.string().min(1).max(100).describe("Project name"),
9382
9402
  description: z9.string().max(500).optional().describe("Project description"),
9383
- workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory")
9403
+ workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory"),
9404
+ force: z9.boolean().optional().describe(
9405
+ "Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false \u2014 normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
9406
+ )
9384
9407
  },
9385
9408
  async (args) => {
9386
9409
  try {
9410
+ if (!args.force) {
9411
+ const similar = await findSimilarProjects(args.name);
9412
+ if (similar.length > 0) {
9413
+ return ok({
9414
+ status: "similar-found",
9415
+ message: "A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
9416
+ matches: similar
9417
+ });
9418
+ }
9419
+ }
9387
9420
  const now = /* @__PURE__ */ new Date();
9388
9421
  const id = crypto.randomUUID();
9389
9422
  await db.insert(projects).values({
@@ -25202,8 +25235,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25202
25235
  import yaml12 from "js-yaml";
25203
25236
  import semver from "semver";
25204
25237
  function relayCoreVersion() {
25205
- if (semver.valid("0.15.3")) {
25206
- return "0.15.3";
25238
+ if (semver.valid("0.15.5")) {
25239
+ return "0.15.5";
25207
25240
  }
25208
25241
  try {
25209
25242
  const root = getAppRoot(import.meta.dirname, 3);
@@ -26016,7 +26049,13 @@ async function main() {
26016
26049
  RELAY_DATA_DIR: DATA_DIR,
26017
26050
  RELAY_LAUNCH_CWD: launchCwd2,
26018
26051
  PORT: String(actualPort),
26019
- ...opts.safeMode ? { RELAY_SAFE_MODE: "true" } : {}
26052
+ ...opts.safeMode ? { RELAY_SAFE_MODE: "true" } : {},
26053
+ // In dev mode, Next blocks cross-origin /_next/* dev-asset requests from
26054
+ // the LAN client's IP, breaking the app over the network (issue #13).
26055
+ // When the operator has opted into non-loopback binding, tell next.config
26056
+ // to allow any dev origin. Mirrors the same trust decision as the warning
26057
+ // above; harmless for the prebuilt `next start` path (no dev-origin gate).
26058
+ ...isNonLoopbackHost(bindHost) ? { RELAY_ALLOW_LAN_ORIGINS: "true" } : {}
26020
26059
  }
26021
26060
  });
26022
26061
  if (opts.open !== false) {
package/next.config.mjs CHANGED
@@ -1,8 +1,28 @@
1
+ // When the operator opts into LAN binding (`--hostname` to a non-loopback host,
2
+ // see bin/cli.ts), the CLI sets RELAY_ALLOW_LAN_ORIGINS=true. In dev mode Next
3
+ // otherwise blocks cross-origin requests to /_next/* dev assets from the LAN
4
+ // client's IP — which silently breaks the whole app over the network (issue
5
+ // #13). The client IP is unknowable at config-load time (the bind host is
6
+ // 0.0.0.0 = "all interfaces"), and Next's matcher explicitly rejects a bare
7
+ // "*"/"**" catch-all, so we allow every RFC1918 private-network range instead.
8
+ // This matches the "trusted network" assumption the --hostname warning already
9
+ // states, while still blocking public origins. (Prod `next start` has no such
10
+ // gate; this only affects the dev-mode npx path.)
11
+ const RFC1918_DEV_ORIGINS = [
12
+ "10.*.*.*",
13
+ "192.168.*.*",
14
+ // 172.16.0.0/12 — Next's matcher globs per-octet, so enumerate 16–31.
15
+ ...Array.from({ length: 16 }, (_, i) => `172.${16 + i}.*.*`),
16
+ ];
17
+ const allowLanDevOrigins = process.env.RELAY_ALLOW_LAN_ORIGINS === "true";
18
+
1
19
  /** @type {import('next').NextConfig} */
2
20
  const nextConfig = {
3
21
  serverExternalPackages: ["better-sqlite3", "pdf-parse", "pdfjs-dist"],
4
22
  devIndicators: false,
5
- allowedDevOrigins: ["127.0.0.1"],
23
+ allowedDevOrigins: allowLanDevOrigins
24
+ ? ["127.0.0.1", ...RFC1918_DEV_ORIGINS]
25
+ : ["127.0.0.1"],
6
26
  // The in-app kindle reader was removed; the book lives at ainative.business.
7
27
  // Redirect legacy /book links (and any chapter-anchored deep links) there.
8
28
  async redirects() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -68,7 +68,13 @@ const CRITICAL_THEME_CSS = `
68
68
  --surface-2: oklch(0.16 0.02 250);
69
69
  --border: oklch(0.26 0.015 250);
70
70
  }
71
- html { background: var(--background); font-size: 14px; }
71
+ /* Root rem base. Fixed 14px left the whole rem-based design tiny on high-res
72
+ 4K displays (issue #4) — text stayed 14px regardless of viewport, forcing
73
+ browser zoom. This clamp holds a flat 14px through 1920px-wide viewports
74
+ (unchanged for existing laptop/desktop users) and only ramps up on QHD/4K:
75
+ ~16px at 2560px, capped at 18px from 3200px+. Type and spacing grow
76
+ proportionally (the design is rem-based) with no component changes. */
77
+ html { background: var(--background); font-size: clamp(14px, 0.35vw + 7.3px, 18px); }
72
78
  `.replace(/\s+/g, " ").trim();
73
79
 
74
80
  export default async function RootLayout({
@@ -187,7 +187,7 @@ export function InstanceSection() {
187
187
  Running on the main dev repo. Instance upgrade features are disabled.
188
188
  Set{" "}
189
189
  <code className="font-mono text-[11px] px-1 py-0.5 rounded bg-muted">
190
- AINATIVE_INSTANCE_MODE=true
190
+ RELAY_INSTANCE_MODE=true
191
191
  </code>{" "}
192
192
  to test.
193
193
  </p>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import {
5
+ AlertTriangle,
5
6
  ChevronDown,
6
7
  ChevronRight,
7
8
  Network,
@@ -286,6 +287,7 @@ function ProviderRow({
286
287
  export function ProvidersAndRuntimesSection() {
287
288
  const [data, setData] = useState<ProvidersPayload | null>(null);
288
289
  const [loading, setLoading] = useState(true);
290
+ const [error, setError] = useState<string | null>(null);
289
291
  const [anthropicOpen, setAnthropicOpen] = useState(false);
290
292
  const [openAIOpen, setOpenAIOpen] = useState(false);
291
293
  const [openAILoginState, setOpenAILoginState] = useState<OpenAILoginState | null>(null);
@@ -298,8 +300,20 @@ export function ProvidersAndRuntimesSection() {
298
300
  if (res.ok) {
299
301
  const json = (await res.json()) as ProvidersPayload;
300
302
  setData(json);
303
+ setError(null);
301
304
  return json;
302
305
  }
306
+ // A non-OK response left the section spinning forever (issue #9): the
307
+ // render guard is `loading || !data`, so `data` staying null on error
308
+ // meant a permanent "Loading…" card with no visible failure. Surface it.
309
+ setError(`Failed to load provider configuration (HTTP ${res.status}).`);
310
+ return null;
311
+ } catch (err) {
312
+ setError(
313
+ err instanceof Error
314
+ ? `Failed to load provider configuration: ${err.message}`
315
+ : "Failed to load provider configuration.",
316
+ );
303
317
  return null;
304
318
  } finally {
305
319
  setLoading(false);
@@ -630,7 +644,7 @@ export function ProvidersAndRuntimesSection() {
630
644
 
631
645
  // ── Render ───────────────────────────────────────────────────────
632
646
 
633
- if (loading || !data) {
647
+ if (loading) {
634
648
  return (
635
649
  <Card className="surface-card">
636
650
  <CardHeader>
@@ -644,6 +658,38 @@ export function ProvidersAndRuntimesSection() {
644
658
  );
645
659
  }
646
660
 
661
+ // Fetch finished but produced no data (non-OK response or thrown error).
662
+ // Show an actionable error with a retry instead of an endless spinner —
663
+ // the previous `loading || !data` guard silently hung here (issue #9).
664
+ if (!data) {
665
+ return (
666
+ <Card className="surface-card">
667
+ <CardHeader>
668
+ <CardTitle className="flex items-center gap-2">
669
+ <AlertTriangle className="h-5 w-5 text-destructive" />
670
+ Providers &amp; Runtimes
671
+ </CardTitle>
672
+ <CardDescription>
673
+ {error ?? "Failed to load provider configuration."}
674
+ </CardDescription>
675
+ </CardHeader>
676
+ <CardContent>
677
+ <button
678
+ type="button"
679
+ onClick={() => {
680
+ setLoading(true);
681
+ setError(null);
682
+ void fetchData();
683
+ }}
684
+ className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
685
+ >
686
+ Retry
687
+ </button>
688
+ </CardContent>
689
+ </Card>
690
+ );
691
+ }
692
+
647
693
  const { providers, routingPreference, configuredProviderCount } = data;
648
694
  const openAIProvider: ProviderState = {
649
695
  ...providers.openai,
@@ -672,9 +672,32 @@ export async function* sendMessage(
672
672
  const pendingScreenshotTools = new Set<string>(); // tool_use IDs for screenshot tools
673
673
  const screenshotAttachments: ScreenshotAttachment[] = [];
674
674
 
675
- for await (const raw of response as AsyncIterable<
676
- Record<string, unknown>
677
- >) {
675
+ // Race the SDK iterator against the permission side-channel.
676
+ //
677
+ // The Agent SDK's canUseTool callback pauses the SDK indefinitely while a
678
+ // permission gate is pending (see docs: "Execution remains paused until
679
+ // your callback returns"). During that pause the SDK emits NO events, so a
680
+ // plain `for await` over the iterator parks — and any UI event a *second*
681
+ // concurrent gate pushes onto the side-channel would sit undrained until
682
+ // the 120s auto-deny fired. That was the "silent second gate" deadlock:
683
+ // the loop's only driver (SDK events) is exactly what the pending gate
684
+ // stalls.
685
+ //
686
+ // Instead we drive the iterator manually and race each `.next()` against a
687
+ // blocking `sideChannel.pull()`. When a side-channel event wins (a gate
688
+ // surfacing while the SDK is paused), we yield it immediately and loop
689
+ // again WITHOUT re-issuing `.next()` — the outstanding SDK promise is kept
690
+ // in `sdkNext` and only replaced once it resolves, so we never call
691
+ // `.next()` twice concurrently. This mirrors the codex-engine wake-signal
692
+ // loop, which never had this bug.
693
+ const sdkIterator = (response as AsyncIterable<Record<string, unknown>>)[
694
+ Symbol.asyncIterator
695
+ ]();
696
+ let sdkNext: Promise<IteratorResult<Record<string, unknown>>> | null =
697
+ sdkIterator.next();
698
+ let sdkDone = false;
699
+
700
+ while (!sdkDone) {
678
701
  if (signal?.aborted) break;
679
702
 
680
703
  // Signal that the model has connected and is processing
@@ -683,7 +706,35 @@ export async function* sendMessage(
683
706
  yield { type: "status", phase: "generating", message: "Generating response..." };
684
707
  }
685
708
 
686
- // Drain any side-channel events (from canUseTool) before processing SDK event
709
+ // Race: whichever of the SDK event or a side-channel push resolves first.
710
+ // The SDK promise is persistent (never re-issued while pending); the pull
711
+ // is recreated each turn and resolves with `undefined` on channel close.
712
+ const winner = await Promise.race([
713
+ sdkNext.then((res) => ({ kind: "sdk" as const, res })),
714
+ sideChannel
715
+ .pull()
716
+ .then((event) => ({ kind: "side-channel" as const, event })),
717
+ ]);
718
+
719
+ // Side-channel won: the SDK is (likely) paused on a gate. Surface the
720
+ // event now and loop again — `sdkNext` stays pending, unresolved.
721
+ if (winner.kind === "side-channel") {
722
+ // `undefined` means the channel closed (turn ending); ignore it.
723
+ if (winner.event) yield winner.event;
724
+ continue;
725
+ }
726
+
727
+ // SDK event won: consume it and re-arm the iterator for the next turn.
728
+ const { res } = winner;
729
+ sdkNext = null;
730
+ if (res.done) {
731
+ sdkDone = true;
732
+ break;
733
+ }
734
+ const raw = res.value;
735
+ sdkNext = sdkIterator.next();
736
+
737
+ // Drain any side-channel events buffered alongside this SDK event.
687
738
  for (const sideEvent of sideChannel.drain()) {
688
739
  yield sideEvent;
689
740
  }
@@ -40,7 +40,7 @@ const pendingRequests = new Map<string, PendingRequest>();
40
40
  */
41
41
  export class AsyncQueue<T> {
42
42
  private buffer: T[] = [];
43
- private waiters: Array<(value: T) => void> = [];
43
+ private waiters: Array<(value: T | undefined) => void> = [];
44
44
  private closed = false;
45
45
 
46
46
  push(item: T) {
@@ -60,12 +60,35 @@ export class AsyncQueue<T> {
60
60
  return items;
61
61
  }
62
62
 
63
- /** Close the queue — any pending waiters will reject */
63
+ /**
64
+ * Block until the next item is available, then resolve with it.
65
+ *
66
+ * This is the wake-signal the Claude chat engine races against the SDK
67
+ * iterator: when `canUseTool` pauses the SDK (no more SDK events until a
68
+ * gate resolves), a pull() that began awaiting before the next
69
+ * `emitSideChannelEvent` still resolves the moment that event is pushed —
70
+ * so a second permission gate surfaces immediately instead of stalling
71
+ * until the 120s auto-deny. Resolves with `undefined` (not a rejection)
72
+ * when the queue closes, so a losing race branch never throws.
73
+ */
74
+ pull(): Promise<T | undefined> {
75
+ if (this.buffer.length > 0) {
76
+ return Promise.resolve(this.buffer.shift());
77
+ }
78
+ if (this.closed) {
79
+ return Promise.resolve(undefined);
80
+ }
81
+ return new Promise<T | undefined>((resolve) => {
82
+ this.waiters.push(resolve);
83
+ });
84
+ }
85
+
86
+ /** Close the queue — any pending waiters resolve with the `undefined` sentinel */
64
87
  close() {
65
88
  this.closed = true;
66
89
  this.buffer = [];
67
90
  for (const waiter of this.waiters) {
68
- // Resolve with a sentinel — callers check isClosed()
91
+ waiter(undefined);
69
92
  }
70
93
  this.waiters = [];
71
94
  }
@@ -128,6 +128,7 @@ Be proactive with tools. If the user asks about project status, use list_tasks t
128
128
  - For workflows, valid patterns are: sequence, parallel, checkpoint, planner-executor, swarm, loop.
129
129
  - **Delay steps** (sequence pattern only): a step with \`delayDuration\` (format: Nm|Nh|Nd|Nw, bounds 1m..30d) pauses the workflow between task steps. Format examples: "30m", "2h", "3d", "1w". Delay steps must have NO profile or prompt — they are pure waits. Use them for outreach sequences, drip campaigns, cooling periods, staged rollouts. A paused workflow resumes automatically when its scheduled time arrives, or immediately when the user clicks "Resume Now".
130
130
  - **enrich_table idempotency:** \`enrich_table\` skips rows where the target column already has a non-empty value. If the user wants to overwrite existing values, explain that force re-enrichment is not supported in v1 — they must manually clear the target column first (e.g. via update_row) before re-running.
131
+ - **create_project dedup / reuse:** When composing an app for a named client, reuse an existing project instead of creating a duplicate. \`create_project\` performs its own exact-name check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when a same-named project already exists — when that happens, use the returned project \`id\` for every subsequent artifact (profiles, tables, workflows, schedules) rather than retrying \`create_project\`. Only pass \`force: true\` when the user has explicitly confirmed they want a second same-named project.
131
132
  - **create_workflow dedup:** Before calling \`create_workflow\`, call \`list_workflows\` (filtered by the current project) to check whether a similar workflow already exists. If the user asks to "redesign", "redo", or "update" an existing workflow, call \`update_workflow\` on the matching row instead of creating a new one. \`create_workflow\` performs its own near-duplicate check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when it finds one — when that happens, surface the matches to the user and confirm intent. Only pass \`force: true\` to \`create_workflow\` when the user has explicitly confirmed they want a second workflow alongside a similar one (e.g., "v2", "alternate approach").
132
133
  - When a working directory is specified, always create files relative to it. Never assume the git root is the working directory — they may differ in worktree environments.
133
134
 
@@ -7,6 +7,54 @@ import { ok, err, type ToolContext } from "./helpers";
7
7
 
8
8
  const VALID_PROJECT_STATUSES = ["active", "paused", "completed"] as const;
9
9
 
10
+ export interface SimilarProjectMatch {
11
+ id: string;
12
+ name: string;
13
+ reason: string;
14
+ }
15
+
16
+ /**
17
+ * Find existing projects that duplicate a candidate by name.
18
+ *
19
+ * Projects are top-level (not scoped like workflows), so this scans all of
20
+ * them. Unlike workflow dedup, there is no fuzzy step-text signal to compare —
21
+ * a project is just a name + description — so this is an exact, case- and
22
+ * whitespace-insensitive name match only. That precisely targets the observed
23
+ * compose bug: a long chat truncates the earlier `create_project` call out of
24
+ * the sliding-window context, so the model re-creates the same named client
25
+ * ("Northstar CRE") instead of reusing it. Fuzzy matching would add
26
+ * false-positive risk with no evidence it is needed (engineering principle #6).
27
+ *
28
+ * Used by `create_project` to warn the model before blindly inserting; bypass
29
+ * with `force: true` when the user genuinely wants a second same-named project.
30
+ */
31
+ export async function findSimilarProjects(
32
+ candidateName: string
33
+ ): Promise<SimilarProjectMatch[]> {
34
+ const candidateNameLower = candidateName.trim().toLowerCase();
35
+ if (!candidateNameLower) return [];
36
+
37
+ const existing = await db
38
+ .select({
39
+ id: projects.id,
40
+ name: projects.name,
41
+ description: projects.description,
42
+ })
43
+ .from(projects);
44
+
45
+ const matches: SimilarProjectMatch[] = [];
46
+ for (const row of existing) {
47
+ if (row.name.trim().toLowerCase() === candidateNameLower) {
48
+ matches.push({
49
+ id: row.id,
50
+ name: row.name,
51
+ reason: `Same name: "${row.name}"`,
52
+ });
53
+ }
54
+ }
55
+ return matches;
56
+ }
57
+
10
58
  export function projectTools(ctx: ToolContext) {
11
59
  return [
12
60
  defineTool(
@@ -57,9 +105,32 @@ export function projectTools(ctx: ToolContext) {
57
105
  .max(500)
58
106
  .optional()
59
107
  .describe("Absolute path to the project's working directory"),
108
+ force: z
109
+ .boolean()
110
+ .optional()
111
+ .describe(
112
+ "Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false — normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
113
+ ),
60
114
  },
61
115
  async (args) => {
62
116
  try {
117
+ // Dedup guard: in a long compose conversation the sliding-window
118
+ // context can evict the earlier create_project call, so the model
119
+ // re-creates the same named client instead of reusing it. Check for
120
+ // an existing same-named project before inserting; pass force=true to
121
+ // bypass. Mirrors the create_workflow near-duplicate pattern.
122
+ if (!args.force) {
123
+ const similar = await findSimilarProjects(args.name);
124
+ if (similar.length > 0) {
125
+ return ok({
126
+ status: "similar-found",
127
+ message:
128
+ "A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
129
+ matches: similar,
130
+ });
131
+ }
132
+ }
133
+
63
134
  const now = new Date();
64
135
  const id = crypto.randomUUID();
65
136