@enter-pro/enter-cli 0.4.1 → 0.4.3
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/README.md +167 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +132 -6
- package/dist/client.d.ts +4 -6
- package/dist/client.js +97 -93
- package/dist/commands/config.js +5 -10
- package/dist/commands/domain.js +3 -6
- package/dist/commands/login.js +18 -12
- package/dist/commands/logout.js +7 -3
- package/dist/commands/project.js +85 -70
- package/dist/commands/thread-tasks.d.ts +2 -0
- package/dist/commands/thread-tasks.js +23 -0
- package/dist/commands/thread.d.ts +54 -0
- package/dist/commands/thread.js +547 -209
- package/dist/commands/whoami.js +1 -1
- package/dist/commands/workspace.js +7 -10
- package/dist/config.d.ts +0 -1
- package/dist/config.js +10 -4
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +29 -0
- package/dist/index.js +9 -2
- package/dist/output.d.ts +0 -16
- package/dist/output.js +0 -18
- package/dist/poll.d.ts +1 -0
- package/dist/poll.js +3 -1
- package/dist/safe-output.d.ts +6 -0
- package/dist/safe-output.js +29 -0
- package/dist/thread-events.d.ts +37 -0
- package/dist/thread-events.js +196 -0
- package/dist/workflow.d.ts +44 -0
- package/dist/workflow.js +34 -0
- package/package.json +22 -10
- package/scripts/install-hosts.mjs +29 -0
- package/skills/enter/SKILL.md +36 -0
- package/skills/enter/references/configuration.md +19 -0
package/dist/commands/whoami.js
CHANGED
|
@@ -6,7 +6,7 @@ export const whoamiCmd = new Command("whoami")
|
|
|
6
6
|
.description("Show current user info")
|
|
7
7
|
.action(async (_opts, cmd) => {
|
|
8
8
|
if (!isAuthenticated()) {
|
|
9
|
-
throw new Error("Not authenticated. Run `enter login` or set ENTER_API_KEY environment variable.");
|
|
9
|
+
throw new Error("Not authenticated. Run `enter-cli login` or set ENTER_API_KEY environment variable.");
|
|
10
10
|
}
|
|
11
11
|
const data = await client.get("/v1/users/info");
|
|
12
12
|
const format = cmd.optsWithGlobals().output || "json";
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import * as client from "../client.js";
|
|
3
|
-
import { print,
|
|
3
|
+
import { print, printResult, printTable, pickList, getFormat } from "../output.js";
|
|
4
4
|
export const workspaceCmd = new Command("workspace")
|
|
5
5
|
.alias("ws")
|
|
6
6
|
.description("Manage workspaces");
|
|
7
|
-
function getFormat(cmd) {
|
|
8
|
-
return cmd.optsWithGlobals().output || "json";
|
|
9
|
-
}
|
|
10
7
|
workspaceCmd
|
|
11
8
|
.command("list")
|
|
12
9
|
.description("List workspaces")
|
|
@@ -68,9 +65,9 @@ workspaceCmd
|
|
|
68
65
|
workspaceCmd
|
|
69
66
|
.command("delete <workspace_id>")
|
|
70
67
|
.description("Delete a workspace")
|
|
71
|
-
.action(async (id) => {
|
|
68
|
+
.action(async (id, _opts, cmd) => {
|
|
72
69
|
await client.del(`/v1/workspaces/${id}`);
|
|
73
|
-
|
|
70
|
+
printResult(getFormat(cmd), { deleted: true, workspace_id: id }, "Workspace deleted successfully.");
|
|
74
71
|
});
|
|
75
72
|
// Members subcommand group
|
|
76
73
|
const membersCmd = new Command("members").description("Manage workspace members");
|
|
@@ -118,7 +115,7 @@ membersCmd
|
|
|
118
115
|
.description("Remove a member from workspace")
|
|
119
116
|
.option("--email <email>", "Member email")
|
|
120
117
|
.option("--user-id <id>", "Member user ID")
|
|
121
|
-
.action(async (id, opts) => {
|
|
118
|
+
.action(async (id, opts, cmd) => {
|
|
122
119
|
if (!opts.email && !opts.userId) {
|
|
123
120
|
throw new Error("--email or --user-id is required");
|
|
124
121
|
}
|
|
@@ -128,7 +125,7 @@ membersCmd
|
|
|
128
125
|
if (opts.userId)
|
|
129
126
|
body.user_id = Number(opts.userId);
|
|
130
127
|
await client.post(`/v1/workspaces/${id}/members/remove`, body);
|
|
131
|
-
|
|
128
|
+
printResult(getFormat(cmd), { removed: true, workspace_id: id }, "Member removed successfully.");
|
|
132
129
|
});
|
|
133
130
|
membersCmd
|
|
134
131
|
.command("update-role <workspace_id>")
|
|
@@ -155,9 +152,9 @@ membersCmd
|
|
|
155
152
|
membersCmd
|
|
156
153
|
.command("leave <workspace_id>")
|
|
157
154
|
.description("Leave a workspace")
|
|
158
|
-
.action(async (id) => {
|
|
155
|
+
.action(async (id, _opts, cmd) => {
|
|
159
156
|
await client.post(`/v1/workspaces/${id}/leave`);
|
|
160
|
-
|
|
157
|
+
printResult(getFormat(cmd), { left: true, workspace_id: id }, "Left workspace successfully.");
|
|
161
158
|
});
|
|
162
159
|
workspaceCmd.addCommand(membersCmd);
|
|
163
160
|
// Credits subcommand group
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -8,7 +8,6 @@ const defaults = {
|
|
|
8
8
|
api_url: "https://api.enter.pro",
|
|
9
9
|
base_path: "/code/api",
|
|
10
10
|
output: "json",
|
|
11
|
-
default_workspace: "",
|
|
12
11
|
};
|
|
13
12
|
function ensureConfigDir() {
|
|
14
13
|
if (!existsSync(CONFIG_DIR)) {
|
|
@@ -32,8 +31,6 @@ function getEnvOverrides() {
|
|
|
32
31
|
overrides.base_path = process.env.ENTER_BASE_PATH;
|
|
33
32
|
if (process.env.ENTER_OUTPUT)
|
|
34
33
|
overrides.output = process.env.ENTER_OUTPUT;
|
|
35
|
-
if (process.env.ENTER_DEFAULT_WORKSPACE)
|
|
36
|
-
overrides.default_workspace = process.env.ENTER_DEFAULT_WORKSPACE;
|
|
37
34
|
return overrides;
|
|
38
35
|
}
|
|
39
36
|
export function configDir() {
|
|
@@ -42,15 +39,24 @@ export function configDir() {
|
|
|
42
39
|
export function loadConfig() {
|
|
43
40
|
const fileConfig = loadFromFile();
|
|
44
41
|
const envOverrides = getEnvOverrides();
|
|
45
|
-
|
|
42
|
+
const merged = { ...defaults, ...fileConfig, ...envOverrides };
|
|
43
|
+
return Object.fromEntries(Object.keys(defaults).map(key => [key, merged[key]]));
|
|
46
44
|
}
|
|
47
45
|
export function setConfig(key, value) {
|
|
46
|
+
validateKey(key);
|
|
47
|
+
if (key === "output" && !["json", "yaml", "table"].includes(value))
|
|
48
|
+
throw new Error("output must be json, yaml or table");
|
|
48
49
|
ensureConfigDir();
|
|
49
50
|
const current = loadFromFile();
|
|
50
51
|
current[key] = value;
|
|
51
52
|
writeFileSync(CONFIG_FILE, yaml.dump(current), "utf-8");
|
|
52
53
|
}
|
|
54
|
+
function validateKey(key) {
|
|
55
|
+
if (!Object.hasOwn(defaults, key))
|
|
56
|
+
throw new Error(`Unsupported setting "${key}". Supported settings: ${Object.keys(defaults).join(", ")}. Pass workspace IDs explicitly to project commands.`);
|
|
57
|
+
}
|
|
53
58
|
export function getConfig(key) {
|
|
59
|
+
validateKey(key);
|
|
54
60
|
const cfg = loadConfig();
|
|
55
61
|
return cfg[key] || "";
|
|
56
62
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export declare class RequestError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
retryable: boolean;
|
|
4
|
+
outcomeUnknown: boolean;
|
|
5
|
+
causeCode?: string | undefined;
|
|
6
|
+
constructor(code: string, message: string, retryable?: boolean, outcomeUnknown?: boolean, causeCode?: string | undefined);
|
|
7
|
+
}
|
|
8
|
+
export declare function errorEnvelope(error: unknown): Record<string, unknown>;
|
|
1
9
|
export declare class APIError extends Error {
|
|
2
10
|
code: number | string;
|
|
3
11
|
detail: string;
|
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
import { redactText } from "./safe-output.js";
|
|
2
|
+
export class RequestError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
retryable;
|
|
5
|
+
outcomeUnknown;
|
|
6
|
+
causeCode;
|
|
7
|
+
constructor(code, message, retryable = false, outcomeUnknown = false, causeCode) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.retryable = retryable;
|
|
11
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
12
|
+
this.causeCode = causeCode;
|
|
13
|
+
this.name = "RequestError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function errorEnvelope(error) {
|
|
17
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
18
|
+
const request = e instanceof RequestError ? e : undefined;
|
|
19
|
+
const api = e instanceof APIError ? e : undefined;
|
|
20
|
+
return { error: {
|
|
21
|
+
code: request?.code ?? api?.code ?? "CLI_ERROR",
|
|
22
|
+
message: redactText(e.message),
|
|
23
|
+
retryable: request?.retryable ?? api?.retryable ?? false,
|
|
24
|
+
outcome_unknown: request?.outcomeUnknown ?? false,
|
|
25
|
+
...(request?.causeCode ? { cause_code: request.causeCode } : {}),
|
|
26
|
+
...(request?.outcomeUnknown ? { hint: "The write may have reached Enter. Inspect task/action state before retrying; do not resubmit blindly." }
|
|
27
|
+
: api?.hint ? { hint: api.hint } : {}),
|
|
28
|
+
} };
|
|
29
|
+
}
|
|
1
30
|
export class APIError extends Error {
|
|
2
31
|
code;
|
|
3
32
|
detail;
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
|
-
import { setVerbose } from "./client.js";
|
|
4
|
+
import { setVerbose, setRequestTimeout } from "./client.js";
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
|
+
import { errorEnvelope } from "./errors.js";
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { version } = require("../package.json");
|
|
8
9
|
import { loginCmd } from "./commands/login.js";
|
|
@@ -21,10 +22,13 @@ program
|
|
|
21
22
|
.version(version)
|
|
22
23
|
.option("-o, --output <format>", "Output format: table, json, yaml")
|
|
23
24
|
.option("-v, --verbose", "Verbose output (show HTTP requests)")
|
|
25
|
+
.option("--request-timeout <seconds>", "Optional HTTP deadline including observation read retries")
|
|
24
26
|
.hook("preAction", () => {
|
|
25
27
|
const opts = program.opts();
|
|
26
28
|
if (opts.verbose)
|
|
27
29
|
setVerbose(true);
|
|
30
|
+
if (opts.requestTimeout !== undefined)
|
|
31
|
+
setRequestTimeout(Number(opts.requestTimeout));
|
|
28
32
|
if (!opts.output) {
|
|
29
33
|
const cfg = loadConfig();
|
|
30
34
|
program.setOptionValue("output", cfg.output);
|
|
@@ -40,6 +44,9 @@ program.addCommand(threadCmd);
|
|
|
40
44
|
program.addCommand(domainCmd);
|
|
41
45
|
program.addCommand(modelsCmd);
|
|
42
46
|
program.parseAsync().catch((err) => {
|
|
43
|
-
|
|
47
|
+
if ((program.opts().output ?? loadConfig().output) === "json")
|
|
48
|
+
console.error(JSON.stringify(errorEnvelope(err)));
|
|
49
|
+
else
|
|
50
|
+
console.error(`Error: ${err.message}`);
|
|
44
51
|
process.exit(1);
|
|
45
52
|
});
|
package/dist/output.d.ts
CHANGED
|
@@ -6,21 +6,5 @@ export declare function printMessage(msg: string): void;
|
|
|
6
6
|
export declare function printResult(format: string, structured: unknown, message: string): void;
|
|
7
7
|
import type { Command } from "commander";
|
|
8
8
|
export declare function getFormat(cmd: Command): string;
|
|
9
|
-
export declare function printError(err: Error | string): void;
|
|
10
9
|
export declare function pick<T extends Record<string, unknown>>(obj: T, keys: string[]): Record<string, unknown>;
|
|
11
10
|
export declare function pickList(items: Record<string, unknown>[], keys: string[]): Record<string, unknown>[];
|
|
12
|
-
export interface ListEnvelope {
|
|
13
|
-
items: unknown[];
|
|
14
|
-
total?: number;
|
|
15
|
-
page?: number;
|
|
16
|
-
page_size?: number;
|
|
17
|
-
}
|
|
18
|
-
export interface TableConfig {
|
|
19
|
-
headers: string[];
|
|
20
|
-
rowMapper: (item: Record<string, unknown>) => string[];
|
|
21
|
-
}
|
|
22
|
-
export declare function printList(format: string, data: ListEnvelope, tableConfig?: TableConfig): void;
|
|
23
|
-
export declare function printSingle(format: string, data: unknown, tableConfig?: {
|
|
24
|
-
headers: string[];
|
|
25
|
-
rowMapper: (item: Record<string, unknown>) => string[];
|
|
26
|
-
}): void;
|
package/dist/output.js
CHANGED
|
@@ -59,9 +59,6 @@ export function printResult(format, structured, message) {
|
|
|
59
59
|
export function getFormat(cmd) {
|
|
60
60
|
return cmd.optsWithGlobals().output || "json";
|
|
61
61
|
}
|
|
62
|
-
export function printError(err) {
|
|
63
|
-
console.error(`Error: ${typeof err === "string" ? err : err.message}`);
|
|
64
|
-
}
|
|
65
62
|
export function pick(obj, keys) {
|
|
66
63
|
const result = {};
|
|
67
64
|
for (const key of keys) {
|
|
@@ -72,18 +69,3 @@ export function pick(obj, keys) {
|
|
|
72
69
|
export function pickList(items, keys) {
|
|
73
70
|
return items.map((item) => pick(item, keys));
|
|
74
71
|
}
|
|
75
|
-
export function printList(format, data, tableConfig) {
|
|
76
|
-
if (format === "table" && tableConfig) {
|
|
77
|
-
const rows = data.items.map(tableConfig.rowMapper);
|
|
78
|
-
printTable(tableConfig.headers, rows);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
print(format, data);
|
|
82
|
-
}
|
|
83
|
-
export function printSingle(format, data, tableConfig) {
|
|
84
|
-
if (format === "table" && tableConfig) {
|
|
85
|
-
printTable(tableConfig.headers, [tableConfig.rowMapper(data)]);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
print(format, data);
|
|
89
|
-
}
|
package/dist/poll.d.ts
CHANGED
package/dist/poll.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
1
2
|
export class TimeoutError extends Error {
|
|
2
3
|
constructor(elapsedMs) {
|
|
3
4
|
super(`Timed out after ${Math.round(elapsedMs / 1000)}s`);
|
|
@@ -10,6 +11,7 @@ export async function pollUntil(fetcher, predicate, options) {
|
|
|
10
11
|
const onTick = options?.onTick;
|
|
11
12
|
const start = Date.now();
|
|
12
13
|
while (true) {
|
|
14
|
+
options?.signal?.throwIfAborted();
|
|
13
15
|
const data = await fetcher();
|
|
14
16
|
const elapsed = Date.now() - start;
|
|
15
17
|
if (predicate(data)) {
|
|
@@ -19,6 +21,6 @@ export async function pollUntil(fetcher, predicate, options) {
|
|
|
19
21
|
throw new TimeoutError(elapsed);
|
|
20
22
|
}
|
|
21
23
|
onTick?.(elapsed, data);
|
|
22
|
-
await
|
|
24
|
+
await delay(Math.min(intervalMs, Math.max(0, timeoutMs - elapsed)), undefined, { signal: options?.signal });
|
|
23
25
|
}
|
|
24
26
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Applied at output boundaries, never to requests or data used for execution.
|
|
2
|
+
const credentialField = /^(?:.*[_-])?(?:access_token|refresh_token|id_token|token|password|secret|secret_value|secret_key|api_key|anon_key|service_role_key|private_key|client_secret|app_secret|authorization|credentials?)$/i;
|
|
3
|
+
export function redactText(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
|
|
6
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]")
|
|
7
|
+
.replace(/\b(?:epk_|sk-)[A-Za-z0-9_-]+/g, "[REDACTED]")
|
|
8
|
+
.replace(/([?&](?:token|api_key|access_token)=)[^&\s"']+/gi, "$1[REDACTED]");
|
|
9
|
+
}
|
|
10
|
+
export function safeOutput(value, options = {}) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.map(item => safeOutput(item, options));
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
15
|
+
key, credentialField.test(key.replace(/([a-z])([A-Z])/g, "$1_$2")) || (options.redactIdentifiers && /^(client_ids?|app_id)$/i.test(key)) ? "[REDACTED]" : safeField(key, item, options),
|
|
16
|
+
]));
|
|
17
|
+
}
|
|
18
|
+
return typeof value === "string" ? redactText(value) : value;
|
|
19
|
+
}
|
|
20
|
+
function safeField(key, value, options) {
|
|
21
|
+
// Only decode known JSON-encoded tool fields, never arbitrary user content.
|
|
22
|
+
if (["full_arguments", "tool_call_args", "tool_result", "accumulated_arguments", "arguments"].includes(key) && typeof value === "string") {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(safeOutput(JSON.parse(value), options));
|
|
25
|
+
}
|
|
26
|
+
catch { /* partial JSON */ }
|
|
27
|
+
}
|
|
28
|
+
return safeOutput(value, options);
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RequestError } from "./errors.js";
|
|
2
|
+
export type ThreadEvent = Record<string, unknown>;
|
|
3
|
+
export type StreamMode = "connecting" | "websocket" | "reconnecting" | "polling";
|
|
4
|
+
export interface StreamOptions {
|
|
5
|
+
turn?: number;
|
|
6
|
+
chatId?: string;
|
|
7
|
+
cursor?: string;
|
|
8
|
+
stateOnly?: boolean;
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
onEvent?: (event: ThreadEvent) => void;
|
|
11
|
+
onMode?: (mode: StreamMode) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function streamURL(base: string, projectId: string, options: Omit<StreamOptions, "signal">): URL;
|
|
14
|
+
export declare class ThreadEvents {
|
|
15
|
+
private projectId;
|
|
16
|
+
private options;
|
|
17
|
+
mode: StreamMode;
|
|
18
|
+
cursor?: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
failure?: RequestError;
|
|
21
|
+
revision: number;
|
|
22
|
+
private socket?;
|
|
23
|
+
private retry?;
|
|
24
|
+
private stopped;
|
|
25
|
+
private failures;
|
|
26
|
+
private listeners;
|
|
27
|
+
private seen;
|
|
28
|
+
private abort;
|
|
29
|
+
constructor(projectId: string, options: StreamOptions);
|
|
30
|
+
private wake;
|
|
31
|
+
private setMode;
|
|
32
|
+
private connect;
|
|
33
|
+
private connectAuthenticated;
|
|
34
|
+
wait(afterRevision: number, timeoutMs: number): Promise<void>;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
export declare function isStateEvent(event: ThreadEvent): boolean;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
3
|
+
import { getProxyForUrl } from "proxy-from-env";
|
|
4
|
+
import { baseURL } from "./config.js";
|
|
5
|
+
import { getValidToken } from "./auth.js";
|
|
6
|
+
import { RequestError } from "./errors.js";
|
|
7
|
+
export function streamURL(base, projectId, options) {
|
|
8
|
+
const url = new URL(`${base.replace(/\/$/, "")}/v1/projects/${encodeURIComponent(projectId)}/thread/stream`);
|
|
9
|
+
if (url.protocol === "https:")
|
|
10
|
+
url.protocol = "wss:";
|
|
11
|
+
else if (url.protocol === "http:")
|
|
12
|
+
url.protocol = "ws:";
|
|
13
|
+
else
|
|
14
|
+
throw new Error("Enter API URL must use HTTP or HTTPS");
|
|
15
|
+
if (options.turn)
|
|
16
|
+
url.searchParams.set("turn", String(options.turn));
|
|
17
|
+
if (options.chatId)
|
|
18
|
+
url.searchParams.set("chat_id", options.chatId);
|
|
19
|
+
if (options.cursor)
|
|
20
|
+
url.searchParams.set("last_event_id", options.cursor);
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
function laterCursor(current, next) {
|
|
24
|
+
if (typeof next !== "string" || !/^\d+-\d+$/.test(next))
|
|
25
|
+
return current;
|
|
26
|
+
if (!current || !/^\d+-\d+$/.test(current))
|
|
27
|
+
return next;
|
|
28
|
+
const a = current.split("-").map(BigInt), b = next.split("-").map(BigInt);
|
|
29
|
+
return b[0] > a[0] || (b[0] === a[0] && b[1] > a[1]) ? next : current;
|
|
30
|
+
}
|
|
31
|
+
// A socket is only a wake-up source. Callers still read authoritative action
|
|
32
|
+
// state: a tool-start event can precede persistence of its approval card.
|
|
33
|
+
export class ThreadEvents {
|
|
34
|
+
projectId;
|
|
35
|
+
options;
|
|
36
|
+
mode = "connecting";
|
|
37
|
+
cursor;
|
|
38
|
+
reason;
|
|
39
|
+
failure;
|
|
40
|
+
revision = 0;
|
|
41
|
+
socket;
|
|
42
|
+
retry;
|
|
43
|
+
stopped = false;
|
|
44
|
+
failures = 0;
|
|
45
|
+
listeners = new Set();
|
|
46
|
+
seen = new Set();
|
|
47
|
+
abort = () => this.close();
|
|
48
|
+
constructor(projectId, options) {
|
|
49
|
+
this.projectId = projectId;
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.cursor = options.cursor;
|
|
52
|
+
options.signal.addEventListener("abort", this.abort, { once: true });
|
|
53
|
+
if (options.signal.aborted)
|
|
54
|
+
this.close();
|
|
55
|
+
else {
|
|
56
|
+
try {
|
|
57
|
+
this.connect();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
this.close();
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
wake() {
|
|
66
|
+
this.revision++;
|
|
67
|
+
for (const listener of this.listeners)
|
|
68
|
+
listener();
|
|
69
|
+
this.listeners.clear();
|
|
70
|
+
}
|
|
71
|
+
setMode(mode, reason) {
|
|
72
|
+
if (this.mode === mode && this.reason === reason)
|
|
73
|
+
return;
|
|
74
|
+
this.mode = mode;
|
|
75
|
+
this.reason = reason;
|
|
76
|
+
this.options.onMode?.(mode);
|
|
77
|
+
this.wake();
|
|
78
|
+
}
|
|
79
|
+
connect() {
|
|
80
|
+
void this.connectAuthenticated().catch(error => {
|
|
81
|
+
if (this.stopped)
|
|
82
|
+
return;
|
|
83
|
+
this.failure = new RequestError("STREAM_SETUP_ERROR", error instanceof Error ? error.message : "Could not initialize Enter event stream.");
|
|
84
|
+
this.setMode("polling", "stream_setup_failed");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async connectAuthenticated() {
|
|
88
|
+
if (this.stopped)
|
|
89
|
+
return;
|
|
90
|
+
const url = streamURL(baseURL(), this.projectId, { ...this.options, cursor: this.cursor });
|
|
91
|
+
const token = await getValidToken(this.options.signal);
|
|
92
|
+
if (this.stopped)
|
|
93
|
+
return;
|
|
94
|
+
// proxy-from-env honors NO_PROXY, including loopback fixture servers.
|
|
95
|
+
const proxy = process.env.NODE_USE_ENV_PROXY === "0" ? "" : getProxyForUrl(url.toString().replace(/^ws/, "http"));
|
|
96
|
+
const socket = new WebSocket(url, {
|
|
97
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
98
|
+
...(proxy ? { agent: new HttpsProxyAgent(proxy) } : {}),
|
|
99
|
+
handshakeTimeout: 1500,
|
|
100
|
+
maxPayload: 8 * 1024 * 1024,
|
|
101
|
+
followRedirects: false,
|
|
102
|
+
});
|
|
103
|
+
this.socket = socket;
|
|
104
|
+
let opened = false;
|
|
105
|
+
let retryable = true;
|
|
106
|
+
let reason = "connection_lost";
|
|
107
|
+
socket.on("unexpected-response", (_request, response) => {
|
|
108
|
+
const status = response.statusCode ?? 0;
|
|
109
|
+
response.resume();
|
|
110
|
+
if ([401, 403].includes(status)) {
|
|
111
|
+
retryable = false;
|
|
112
|
+
reason = status === 401 ? "authentication_required" : "permission_denied";
|
|
113
|
+
this.failure = new RequestError("STREAM_AUTH_ERROR", "Enter event stream rejected access. Check authentication and project permissions.");
|
|
114
|
+
}
|
|
115
|
+
else if ([404, 405, 426, 501].includes(status)) {
|
|
116
|
+
retryable = false;
|
|
117
|
+
reason = "stream_unavailable";
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
reason = `stream_http_${status}`;
|
|
121
|
+
socket.terminate();
|
|
122
|
+
});
|
|
123
|
+
socket.on("open", () => { opened = true; this.setMode("websocket"); });
|
|
124
|
+
socket.on("message", raw => {
|
|
125
|
+
let event;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(raw.toString());
|
|
128
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
129
|
+
return;
|
|
130
|
+
event = parsed;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
retryable = false;
|
|
134
|
+
reason = "invalid_event";
|
|
135
|
+
socket.close();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (this.options.chatId && event.chat_id && event.chat_id !== this.options.chatId)
|
|
139
|
+
return;
|
|
140
|
+
const key = typeof event.event_id === "string" ? event.event_id : JSON.stringify(event);
|
|
141
|
+
if (this.seen.has(key))
|
|
142
|
+
return;
|
|
143
|
+
this.seen.add(key);
|
|
144
|
+
if (this.seen.size > 1024)
|
|
145
|
+
this.seen.delete(this.seen.values().next().value);
|
|
146
|
+
this.cursor = laterCursor(this.cursor, event.event_id);
|
|
147
|
+
this.failures = 0;
|
|
148
|
+
this.options.onEvent?.(event);
|
|
149
|
+
if (!this.options.stateOnly || isStateEvent(event))
|
|
150
|
+
this.wake();
|
|
151
|
+
});
|
|
152
|
+
// Do not expose raw URLs/errors (proxies and credentials can be embedded).
|
|
153
|
+
socket.on("error", () => { if (reason === "connection_lost" && !opened)
|
|
154
|
+
reason = "connection_failed"; });
|
|
155
|
+
socket.on("close", () => {
|
|
156
|
+
if (this.stopped)
|
|
157
|
+
return;
|
|
158
|
+
this.failures++;
|
|
159
|
+
this.setMode(opened && retryable ? "reconnecting" : "polling", reason);
|
|
160
|
+
if (!retryable)
|
|
161
|
+
return;
|
|
162
|
+
this.retry = setTimeout(() => this.connect(), Math.min(1000 * 2 ** Math.min(this.failures - 1, 4), 15000));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
wait(afterRevision, timeoutMs) {
|
|
166
|
+
if (this.stopped || this.revision !== afterRevision)
|
|
167
|
+
return Promise.resolve();
|
|
168
|
+
return new Promise(resolve => {
|
|
169
|
+
const done = () => { clearTimeout(timer); this.listeners.delete(done); resolve(); };
|
|
170
|
+
const timer = setTimeout(done, timeoutMs);
|
|
171
|
+
this.listeners.add(done);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
close() {
|
|
175
|
+
if (this.stopped)
|
|
176
|
+
return;
|
|
177
|
+
this.stopped = true;
|
|
178
|
+
clearTimeout(this.retry);
|
|
179
|
+
this.options.signal.removeEventListener("abort", this.abort);
|
|
180
|
+
this.socket?.terminate();
|
|
181
|
+
this.wake();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function isStateEvent(event) {
|
|
185
|
+
const type = String(event.message_type ?? "");
|
|
186
|
+
if (/^(turn_|build_|sync_|agent_stopped|insufficient_credits)/.test(type))
|
|
187
|
+
return true;
|
|
188
|
+
const detail = event.detail;
|
|
189
|
+
const tool = detail?.[type];
|
|
190
|
+
// Legacy argument chunks carry tool_name too; they are not new card state.
|
|
191
|
+
if (["tool_call_arguments", "tool_call_arguments_delta", "tool_call_arguments_start"].includes(type))
|
|
192
|
+
return false;
|
|
193
|
+
if (tool?.tool_action_id)
|
|
194
|
+
return true;
|
|
195
|
+
return (type.startsWith("tool_call_") && /^(ask_user_question|confirm_plan_mode|supabase_|stripe_|enable_|i18n_enable)/.test(String(tool?.tool_name ?? "")));
|
|
196
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type ThreadStatus = 'idle' | 'running' | 'queued' | 'pending' | 'blocked' | 'completed' | 'failed' | 'unknown';
|
|
2
|
+
export type InputKind = 'none' | 'secret' | 'questions' | 'auth_provider';
|
|
3
|
+
export interface WorkflowSnapshot {
|
|
4
|
+
status: ThreadStatus;
|
|
5
|
+
task_id?: unknown;
|
|
6
|
+
turn?: Record<string, unknown> | null;
|
|
7
|
+
actions?: Record<string, unknown>[];
|
|
8
|
+
project?: Record<string, unknown>;
|
|
9
|
+
reason?: string;
|
|
10
|
+
interrupted?: boolean;
|
|
11
|
+
wait_timed_out?: boolean;
|
|
12
|
+
observation_retrying?: boolean;
|
|
13
|
+
error?: {
|
|
14
|
+
retryable?: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Host-facing workflow decisions, independent of transport and backend phases. */
|
|
18
|
+
export declare function workflowResult(snapshot: WorkflowSnapshot): {
|
|
19
|
+
actions?: {
|
|
20
|
+
id: unknown;
|
|
21
|
+
kind: unknown;
|
|
22
|
+
decision: string;
|
|
23
|
+
required_fields: {};
|
|
24
|
+
submit_command: unknown;
|
|
25
|
+
}[] | undefined;
|
|
26
|
+
build?: {
|
|
27
|
+
commit: {} | null;
|
|
28
|
+
matches_task: boolean;
|
|
29
|
+
success: {} | null;
|
|
30
|
+
} | undefined;
|
|
31
|
+
observation: {
|
|
32
|
+
timed_out: boolean;
|
|
33
|
+
retrying: boolean;
|
|
34
|
+
interrupted: boolean;
|
|
35
|
+
};
|
|
36
|
+
reason?: string | undefined;
|
|
37
|
+
state: string;
|
|
38
|
+
task: {
|
|
39
|
+
id: {} | null;
|
|
40
|
+
turn: {} | null;
|
|
41
|
+
chat_id: {} | null;
|
|
42
|
+
};
|
|
43
|
+
next_action: string;
|
|
44
|
+
};
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Host-facing workflow decisions, independent of transport and backend phases. */
|
|
2
|
+
export function workflowResult(snapshot) {
|
|
3
|
+
const turn = snapshot.turn;
|
|
4
|
+
const actions = snapshot.actions ?? [];
|
|
5
|
+
const build = snapshot.project?.build_status;
|
|
6
|
+
const state = snapshot.status === 'blocked' ? 'needs_input'
|
|
7
|
+
: snapshot.status === 'queued' || snapshot.status === 'pending' ? 'running' : snapshot.status;
|
|
8
|
+
const next = snapshot.interrupted ? 'resume_when_requested'
|
|
9
|
+
: snapshot.error && !snapshot.error.retryable ? 'resolve_error'
|
|
10
|
+
: state === 'needs_input' ? 'handle_actions'
|
|
11
|
+
: state === 'completed' ? 'read_delivery'
|
|
12
|
+
: state === 'failed' ? 'report_failure'
|
|
13
|
+
: state === 'idle' ? 'none'
|
|
14
|
+
: 'observe';
|
|
15
|
+
return {
|
|
16
|
+
state,
|
|
17
|
+
task: { id: snapshot.task_id ?? turn?.task_id ?? turn?.id ?? null, turn: turn?.turn ?? null, chat_id: turn?.chat_id ?? null },
|
|
18
|
+
next_action: next,
|
|
19
|
+
...(snapshot.reason ? { reason: snapshot.reason } : {}),
|
|
20
|
+
observation: { timed_out: Boolean(snapshot.wait_timed_out), retrying: Boolean(snapshot.observation_retrying), interrupted: Boolean(snapshot.interrupted) },
|
|
21
|
+
...(snapshot.project ? { build: {
|
|
22
|
+
commit: build?.commit_id ?? null,
|
|
23
|
+
matches_task: Boolean(turn?.commit_id && build?.commit_id === turn.commit_id),
|
|
24
|
+
success: build?.success ?? null,
|
|
25
|
+
} } : {}),
|
|
26
|
+
...(actions.length ? { actions: actions.map(action => ({
|
|
27
|
+
id: action.action_id,
|
|
28
|
+
kind: action.tool_name === 'confirm_plan_mode' ? 'plan' : action.input_kind === 'none' ? 'confirmation' : action.input_kind,
|
|
29
|
+
decision: action.tool_name === 'confirm_plan_mode' || action.input_kind === 'questions' ? 'ask_user' : 'use_existing_authorization',
|
|
30
|
+
required_fields: action.configuration?.fields ?? (action.input_kind === 'secret' ? (action.tool_name === 'supabase_add_secret' ? ['secret_name', 'secret_value'] : ['secret_value']) : []),
|
|
31
|
+
submit_command: action.approve_command,
|
|
32
|
+
})) } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|