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.
|
|
25206
|
-
return "0.15.
|
|
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
package/src/lib/chat/engine.ts
CHANGED
|
@@ -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
|
-
|
|
676
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|