orionfold-relay 0.15.3 → 0.15.4

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.4")) {
25239
+ return "0.15.4";
25207
25240
  }
25208
25241
  try {
25209
25242
  const root = getAppRoot(import.meta.dirname, 3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
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",
@@ -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