@agent-team-foundation/first-tree-hub 0.10.2 → 0.10.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/{bootstrap-Ca5Fiqz6.mjs → bootstrap-jx5nN1qZ.mjs} +2 -2
- package/dist/cli/index.mjs +61 -8
- package/dist/{dist-CLiN7cVS.mjs → dist-CbX9mUVH.mjs} +167 -16
- package/dist/drizzle/0027_runtime_provider.sql +10 -0
- package/dist/drizzle/0028_auth_identity_user_github_unique.sql +12 -0
- package/dist/drizzle/meta/_journal.json +14 -0
- package/dist/{feishu-FTWnoOsc.mjs → feishu-DvjRZMdZ.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/{invitation-C_zAhB8x-8Khychlu.mjs → invitation-BljIolbO-DLeHfURd.mjs} +3 -2
- package/dist/{invitation-BTlGMy0o-dIoR8JRj.mjs → invitation-D3feYxet-366MNOor.mjs} +2 -2
- package/dist/{saas-connect-idjpoPTk.mjs → saas-connect-2puW1r3r.mjs} +1827 -258
- package/dist/web/assets/index-5SNLeFZA.js +392 -0
- package/dist/web/assets/index-BxQQDavm.js +21 -0
- package/dist/web/assets/index-DKZFiOjh.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/assets/index-CEAPwdg7.js +0 -377
- package/dist/web/assets/index-CzWeWItA.css +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
|
|
3
|
-
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue, x as resetConfigMeta } from "./bootstrap-
|
|
4
|
-
import { $ as
|
|
5
|
-
import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-
|
|
3
|
+
import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue, x as resetConfigMeta } from "./bootstrap-jx5nN1qZ.mjs";
|
|
4
|
+
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-CbX9mUVH.mjs";
|
|
5
|
+
import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-BljIolbO-DLeHfURd.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
8
8
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -16,13 +16,14 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
16
16
|
import { parse, stringify } from "yaml";
|
|
17
17
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
18
18
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { Codex } from "@openai/codex-sdk";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
19
21
|
import * as semver from "semver";
|
|
20
22
|
import bcrypt from "bcrypt";
|
|
21
23
|
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
22
24
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
23
25
|
import postgres from "postgres";
|
|
24
26
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
25
|
-
import { fileURLToPath } from "node:url";
|
|
26
27
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
27
28
|
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
|
|
28
29
|
import cors from "@fastify/cors";
|
|
@@ -393,7 +394,8 @@ z.enum([
|
|
|
393
394
|
"not_owned",
|
|
394
395
|
"agent_suspended",
|
|
395
396
|
"wrong_org",
|
|
396
|
-
"unknown_agent"
|
|
397
|
+
"unknown_agent",
|
|
398
|
+
"runtime_provider_mismatch"
|
|
397
399
|
]);
|
|
398
400
|
/** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
|
|
399
401
|
const AGENT_SELECTOR_HEADER = "x-agent-id";
|
|
@@ -421,6 +423,7 @@ z.object({
|
|
|
421
423
|
}),
|
|
422
424
|
clients: z.number().int()
|
|
423
425
|
});
|
|
426
|
+
const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
|
|
424
427
|
const agentTypeSchema = z.enum([
|
|
425
428
|
"human",
|
|
426
429
|
"personal_assistant",
|
|
@@ -462,7 +465,8 @@ z.object({
|
|
|
462
465
|
visibility: agentVisibilitySchema.optional(),
|
|
463
466
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
464
467
|
managerId: z.string().optional(),
|
|
465
|
-
clientId: z.string().min(1).max(100).optional()
|
|
468
|
+
clientId: z.string().min(1).max(100).optional(),
|
|
469
|
+
runtimeProvider: runtimeProviderSchema.optional()
|
|
466
470
|
});
|
|
467
471
|
z.object({
|
|
468
472
|
type: agentTypeSchema.optional(),
|
|
@@ -473,6 +477,11 @@ z.object({
|
|
|
473
477
|
managerId: z.string().nullable().optional(),
|
|
474
478
|
clientId: z.string().min(1).max(100).nullable().optional()
|
|
475
479
|
});
|
|
480
|
+
z.object({
|
|
481
|
+
clientId: z.string().min(1).max(100),
|
|
482
|
+
runtimeProvider: runtimeProviderSchema,
|
|
483
|
+
force: z.boolean().optional()
|
|
484
|
+
});
|
|
476
485
|
z.object({
|
|
477
486
|
uuid: z.string(),
|
|
478
487
|
name: z.string().nullable(),
|
|
@@ -487,6 +496,7 @@ z.object({
|
|
|
487
496
|
metadata: z.record(z.string(), z.unknown()),
|
|
488
497
|
managerId: z.string().nullable(),
|
|
489
498
|
clientId: z.string().nullable(),
|
|
499
|
+
runtimeProvider: runtimeProviderSchema,
|
|
490
500
|
presenceStatus: presenceStatusSchema.optional(),
|
|
491
501
|
createdAt: z.string(),
|
|
492
502
|
updatedAt: z.string()
|
|
@@ -506,14 +516,16 @@ const agentPinnedMessageSchema = z.object({
|
|
|
506
516
|
agentId: z.string(),
|
|
507
517
|
name: z.string().nullable(),
|
|
508
518
|
displayName: z.string(),
|
|
509
|
-
agentType: agentTypeSchema
|
|
519
|
+
agentType: agentTypeSchema,
|
|
520
|
+
runtimeProvider: runtimeProviderSchema
|
|
510
521
|
});
|
|
511
522
|
/**
|
|
512
|
-
* Agent runtime configuration
|
|
523
|
+
* Agent runtime configuration.
|
|
513
524
|
*
|
|
514
525
|
* Defines the 5 user-tunable field groups that the Hub centrally manages
|
|
515
526
|
* and pushes down to the client runtime: prompt append, model, MCP servers,
|
|
516
|
-
* env vars, and Git repos.
|
|
527
|
+
* env vars, and Git repos. Tagged by `kind` (a runtime provider) so future
|
|
528
|
+
* provider-specific fields can land on a dedicated variant.
|
|
517
529
|
*
|
|
518
530
|
* NOTE: do not co-locate with `packages/shared/src/config/` — that namespace
|
|
519
531
|
* is reserved for the local YAML config (`agent.yaml` / server / client) and
|
|
@@ -557,9 +569,11 @@ const gitRepoSchema = z.object({
|
|
|
557
569
|
localPath: z.string().min(1).optional()
|
|
558
570
|
});
|
|
559
571
|
/**
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
572
|
+
* Untagged base shape — 5 user-tunable fields, no `kind` discriminator.
|
|
573
|
+
* Used for `.partial()` derivations on the PATCH side, where `kind` is
|
|
574
|
+
* pinned to `agents.runtime_provider` and never changes via config PATCH.
|
|
575
|
+
* Zod 4 forbids `.partial()` on a refined object, so we keep refinements
|
|
576
|
+
* on the tagged schema below.
|
|
563
577
|
*/
|
|
564
578
|
const agentRuntimeConfigPayloadShape = z.object({
|
|
565
579
|
prompt: promptConfigSchema.default({ append: "" }),
|
|
@@ -568,6 +582,17 @@ const agentRuntimeConfigPayloadShape = z.object({
|
|
|
568
582
|
env: z.array(envEntrySchema).default([]),
|
|
569
583
|
gitRepos: z.array(gitRepoSchema).default([])
|
|
570
584
|
});
|
|
585
|
+
/**
|
|
586
|
+
* Tagged variants — read-side, full payload including `kind`. Adding a new
|
|
587
|
+
* provider means adding a variant here, plus a handler factory and a
|
|
588
|
+
* capability probe module on the client side.
|
|
589
|
+
*
|
|
590
|
+
* Provider-specific fields (e.g. codex `sandboxMode`) belong on the
|
|
591
|
+
* matching variant, not on the base shape.
|
|
592
|
+
*/
|
|
593
|
+
const claudeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("claude-code") });
|
|
594
|
+
const codexRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("codex") });
|
|
595
|
+
const taggedPayloadUnion = z.discriminatedUnion("kind", [claudeRuntimeConfigPayloadShape, codexRuntimeConfigPayloadShape]);
|
|
571
596
|
const payloadDuplicatesRefinement = (payload, ctx) => {
|
|
572
597
|
const seenMcp = /* @__PURE__ */ new Set();
|
|
573
598
|
payload.mcpServers.forEach((server, idx) => {
|
|
@@ -612,7 +637,21 @@ const payloadDuplicatesRefinement = (payload, ctx) => {
|
|
|
612
637
|
seenPaths.add(path);
|
|
613
638
|
});
|
|
614
639
|
};
|
|
615
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Read-side full payload schema. Rows persisted before 0026 do not carry
|
|
642
|
+
* `kind`; `z.preprocess` injects `"claude-code"` so they parse cleanly into
|
|
643
|
+
* the claude variant. The service layer separately enforces
|
|
644
|
+
* `payload.kind === agents.runtime_provider` on writes.
|
|
645
|
+
*/
|
|
646
|
+
const agentRuntimeConfigPayloadSchema = z.preprocess((input) => {
|
|
647
|
+
if (input && typeof input === "object" && !Array.isArray(input) && !("kind" in input)) return {
|
|
648
|
+
...input,
|
|
649
|
+
kind: "claude-code"
|
|
650
|
+
};
|
|
651
|
+
return input;
|
|
652
|
+
}, taggedPayloadUnion).superRefine((payload, ctx) => {
|
|
653
|
+
payloadDuplicatesRefinement(payload, ctx);
|
|
654
|
+
});
|
|
616
655
|
const agentRuntimeConfigSchema = z.object({
|
|
617
656
|
agentId: z.string(),
|
|
618
657
|
version: z.number().int().positive(),
|
|
@@ -715,12 +754,53 @@ z.object({
|
|
|
715
754
|
lastSeenAt: z.string(),
|
|
716
755
|
metadata: z.record(z.string(), z.unknown()).nullable()
|
|
717
756
|
});
|
|
757
|
+
/**
|
|
758
|
+
* Optional opt-in flags the client carries on `client:register` to advertise
|
|
759
|
+
* which negotiable wire-protocol features it implements. Distinct from
|
|
760
|
+
* `clientCapabilitiesSchema` (per-runtime-provider availability — different
|
|
761
|
+
* concept). Older clients omit the field; the server treats every unset flag
|
|
762
|
+
* as `false` and falls back to the legacy path. See proposal
|
|
763
|
+
* hub-inbox-ws-data-plane §3.6.
|
|
764
|
+
*/
|
|
765
|
+
const clientWireCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
|
|
718
766
|
z.object({
|
|
719
767
|
clientId: z.string().min(1).max(100),
|
|
720
768
|
hostname: z.string().max(100).optional(),
|
|
721
769
|
os: z.string().max(50).optional(),
|
|
722
|
-
sdkVersion: z.string().max(50).optional()
|
|
770
|
+
sdkVersion: z.string().max(50).optional(),
|
|
771
|
+
wireCapabilities: clientWireCapabilitiesSchema.optional()
|
|
772
|
+
});
|
|
773
|
+
const capabilityStateSchema = z.enum([
|
|
774
|
+
"ok",
|
|
775
|
+
"missing",
|
|
776
|
+
"unauthenticated",
|
|
777
|
+
"error"
|
|
778
|
+
]);
|
|
779
|
+
const capabilityAuthMethodSchema = z.enum([
|
|
780
|
+
"api_key",
|
|
781
|
+
"oauth",
|
|
782
|
+
"auth_json",
|
|
783
|
+
"none"
|
|
784
|
+
]);
|
|
785
|
+
const capabilityEntrySchema = z.object({
|
|
786
|
+
state: capabilityStateSchema,
|
|
787
|
+
available: z.boolean(),
|
|
788
|
+
authenticated: z.boolean(),
|
|
789
|
+
sdkVersion: z.string().nullable().optional(),
|
|
790
|
+
authMethod: capabilityAuthMethodSchema,
|
|
791
|
+
error: z.string().nullable().optional(),
|
|
792
|
+
detectedAt: z.string()
|
|
723
793
|
});
|
|
794
|
+
/**
|
|
795
|
+
* Capabilities snapshot keyed by runtime provider name. Recorded as a plain
|
|
796
|
+
* `Record<string, CapabilityEntry>` — every entry is optional (a client may
|
|
797
|
+
* report only the runtimes it actually probed) and the key set evolves
|
|
798
|
+
* naturally as new providers ship without a schema migration. Service-layer
|
|
799
|
+
* lookups (`agents.runtime_provider ∈ keys(capabilities)`) treat the keys
|
|
800
|
+
* as `RuntimeProvider` strings.
|
|
801
|
+
*/
|
|
802
|
+
const clientCapabilitiesSchema = z.record(z.string(), capabilityEntrySchema);
|
|
803
|
+
z.object({ capabilities: clientCapabilitiesSchema });
|
|
724
804
|
z.object({
|
|
725
805
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
726
806
|
cursor: z.string().optional()
|
|
@@ -898,11 +978,37 @@ z.object({
|
|
|
898
978
|
ackedAt: z.string().nullable()
|
|
899
979
|
}).extend({ message: clientMessageSchema });
|
|
900
980
|
z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
|
|
981
|
+
/**
|
|
982
|
+
* server → client: a single inbox entry pushed over the active WS connection,
|
|
983
|
+
* replacing the legacy `new_message` doorbell + HTTP `/inbox` poll round-trip.
|
|
984
|
+
*
|
|
985
|
+
* `entryId` is the server-side `inbox_entries.id` the client must echo back
|
|
986
|
+
* in `inbox:ack`. `message` is exactly what the legacy poll path returned —
|
|
987
|
+
* `clientMessageSchema` already carries `precedingMessages`, so the client-
|
|
988
|
+
* side dispatch logic is reused verbatim (see proposal
|
|
989
|
+
* hub-inbox-ws-data-plane §3.1).
|
|
990
|
+
*
|
|
991
|
+
* `.passthrough()` so a forward-rolling server may extend the frame without
|
|
992
|
+
* breaking older clients that validate strictly. Older clients drop unknown
|
|
993
|
+
* fields silently.
|
|
994
|
+
*/
|
|
995
|
+
const inboxDeliverFrameSchema = z.object({
|
|
996
|
+
type: z.literal("inbox:deliver"),
|
|
997
|
+
entryId: z.number().int().nonnegative(),
|
|
998
|
+
inboxId: z.string().min(1),
|
|
999
|
+
chatId: z.string().nullable(),
|
|
1000
|
+
message: clientMessageSchema
|
|
1001
|
+
}).passthrough();
|
|
1002
|
+
z.object({
|
|
1003
|
+
type: z.literal("inbox:ack"),
|
|
1004
|
+
entryId: z.number().int().nonnegative()
|
|
1005
|
+
});
|
|
901
1006
|
z.object({
|
|
902
1007
|
organizationId: z.string(),
|
|
903
1008
|
organizationName: z.string(),
|
|
904
1009
|
organizationDisplayName: z.string(),
|
|
905
|
-
role: z.string()
|
|
1010
|
+
role: z.string(),
|
|
1011
|
+
expiresAt: z.string().nullable()
|
|
906
1012
|
});
|
|
907
1013
|
z.object({
|
|
908
1014
|
id: z.string(),
|
|
@@ -1273,6 +1379,13 @@ z.object({
|
|
|
1273
1379
|
token: z.string().min(1)
|
|
1274
1380
|
});
|
|
1275
1381
|
/**
|
|
1382
|
+
* Negotiable wire-protocol features the server advertises in its `welcome`
|
|
1383
|
+
* frame. Older clients drop the `capabilities` field silently because the
|
|
1384
|
+
* frame is `.passthrough()`. New clients gate optional code paths on it —
|
|
1385
|
+
* absent ⇒ feature off, never assumed.
|
|
1386
|
+
*/
|
|
1387
|
+
const serverCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
|
|
1388
|
+
/**
|
|
1276
1389
|
* Advisory frame sent server → client immediately after `auth:ok`. It carries
|
|
1277
1390
|
* the Command-package version the server was bundled with, so the client can
|
|
1278
1391
|
* detect version drift on startup and on each reconnect. `.passthrough()` so
|
|
@@ -1282,7 +1395,8 @@ z.object({
|
|
|
1282
1395
|
const serverWelcomeFrameSchema = z.object({
|
|
1283
1396
|
type: z.literal("server:welcome"),
|
|
1284
1397
|
serverCommandVersion: z.string().min(1),
|
|
1285
|
-
serverTimeMs: z.number().int().nonnegative()
|
|
1398
|
+
serverTimeMs: z.number().int().nonnegative(),
|
|
1399
|
+
capabilities: serverCapabilitiesSchema.optional()
|
|
1286
1400
|
}).passthrough();
|
|
1287
1401
|
/** Declare a config field with a Zod schema and optional metadata. */
|
|
1288
1402
|
function field(schema, options) {
|
|
@@ -1410,8 +1524,7 @@ defineConfig({
|
|
|
1410
1524
|
clientSecret: field(z.string(), {
|
|
1411
1525
|
env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
|
|
1412
1526
|
secret: true
|
|
1413
|
-
})
|
|
1414
|
-
devCallbackEnabled: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_DEV_CALLBACK" })
|
|
1527
|
+
})
|
|
1415
1528
|
}) }),
|
|
1416
1529
|
cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
|
|
1417
1530
|
rateLimit: optional({
|
|
@@ -1419,6 +1532,7 @@ defineConfig({
|
|
|
1419
1532
|
loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
|
|
1420
1533
|
webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
|
|
1421
1534
|
}),
|
|
1535
|
+
inbox: optional({ maxInFlightPerAgent: field(z.number().int().min(1).max(1024).default(32), { env: "FIRST_TREE_HUB_INBOX_MAX_IN_FLIGHT_PER_AGENT" }) }),
|
|
1422
1536
|
kael: optional({
|
|
1423
1537
|
endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
|
|
1424
1538
|
apiKey: field(z.string(), {
|
|
@@ -1536,6 +1650,25 @@ var FirstTreeHubSDK = class {
|
|
|
1536
1650
|
async fetchAgentConfig() {
|
|
1537
1651
|
return this.requestJson("/api/v1/agent/config");
|
|
1538
1652
|
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Member-scoped: report this client's runtime-provider capabilities. The
|
|
1655
|
+
* server stores them under `clients.metadata.capabilities` after checking
|
|
1656
|
+
* that the connected member owns the client.
|
|
1657
|
+
*/
|
|
1658
|
+
async updateCapabilities(clientId, capabilities) {
|
|
1659
|
+
await this.requestVoid(`/api/v1/clients/${encodeURIComponent(clientId)}/capabilities`, {
|
|
1660
|
+
method: "PATCH",
|
|
1661
|
+
body: JSON.stringify({ capabilities })
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Member-scoped: every agent pinned to a client owned by the calling user.
|
|
1666
|
+
* Used by client startup to reconcile the local `agent.yaml::runtime` with
|
|
1667
|
+
* the authoritative `agents.runtime_provider` before spawning handlers.
|
|
1668
|
+
*/
|
|
1669
|
+
async listMyAgents() {
|
|
1670
|
+
return this.requestJson("/api/v1/clients/me/agents");
|
|
1671
|
+
}
|
|
1539
1672
|
async isHubReachable(timeoutMs = 3e3) {
|
|
1540
1673
|
try {
|
|
1541
1674
|
const url = `${this._baseUrl}/api/v1/health`;
|
|
@@ -1643,6 +1776,17 @@ const RECONNECT_MAX_MS = 3e4;
|
|
|
1643
1776
|
const WS_CONNECT_TIMEOUT_MS = 1e4;
|
|
1644
1777
|
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1645
1778
|
/**
|
|
1779
|
+
* Client-side opt-in for the WS inbox data plane. Gates BOTH the
|
|
1780
|
+
* `wireCapabilities.wsInboxDeliver` flag we declare on `client:register`
|
|
1781
|
+
* AND how we interpret the server's welcome capability — without this AND,
|
|
1782
|
+
* a future client kill-switch could land in a half-state where we tell the
|
|
1783
|
+
* server "no thanks" but still treat welcome's `wsInboxDeliver:true` as
|
|
1784
|
+
* authoritative and stop the 5s HTTP poll, leaving messages stuck if a
|
|
1785
|
+
* NOTIFY ever drops. Hard-coded `true` for now; flip to a config knob if
|
|
1786
|
+
* you need a runtime kill-switch.
|
|
1787
|
+
*/
|
|
1788
|
+
const WS_INBOX_DELIVER_OPT_IN = true;
|
|
1789
|
+
/**
|
|
1646
1790
|
* Unified-user-token C5: reconnect PROACTIVELY this many ms before the JWT's
|
|
1647
1791
|
* `exp` claim so the client rotates to a fresh JWT without ever hitting the
|
|
1648
1792
|
* server-side `auth:expired` push. The provider's next `getAccessToken()` call
|
|
@@ -1694,6 +1838,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1694
1838
|
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
1695
1839
|
welcomeFramesReceived = 0;
|
|
1696
1840
|
/**
|
|
1841
|
+
* Whether the most recent `server:welcome` frame advertised
|
|
1842
|
+
* `capabilities.wsInboxDeliver`. The runtime (AgentSlot) reads this
|
|
1843
|
+
* (via {@link supportsWsInboxDeliver}) to decide whether to keep the
|
|
1844
|
+
* legacy 5s HTTP poll or rely entirely on `inbox:deliver` push frames.
|
|
1845
|
+
* Re-evaluated on every reconnect — the welcome frame is the source of
|
|
1846
|
+
* truth, never assumed sticky across connections.
|
|
1847
|
+
*/
|
|
1848
|
+
wsInboxDeliverActive = false;
|
|
1849
|
+
/**
|
|
1697
1850
|
* Last handshake error, stashed for the `close` handler to surface a typed
|
|
1698
1851
|
* reason (e.g. {@link ClientOrgMismatchError}) instead of a generic
|
|
1699
1852
|
* "closed before ready" when `connect()` is pending.
|
|
@@ -1729,6 +1882,30 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1729
1882
|
get agents() {
|
|
1730
1883
|
return this.boundAgents;
|
|
1731
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* True when the current connection's `server:welcome` advertised
|
|
1887
|
+
* `capabilities.wsInboxDeliver` — meaning the server will push
|
|
1888
|
+
* `inbox:deliver` frames and accept `inbox:ack` frames over this WS.
|
|
1889
|
+
* Resets to false on every reconnect until the new welcome arrives.
|
|
1890
|
+
*/
|
|
1891
|
+
get supportsWsInboxDeliver() {
|
|
1892
|
+
return this.wsInboxDeliverActive;
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Ack a delivered inbox entry over the WS data plane. Replaces the legacy
|
|
1896
|
+
* `sdk.ack()` HTTP call when the connection has negotiated
|
|
1897
|
+
* `wsInboxDeliver`. Safe to call when the WS is closed — the frame is
|
|
1898
|
+
* dropped silently and the entry will time out and re-deliver on
|
|
1899
|
+
* reconnect, mirroring how the legacy timeout reaper handles HTTP
|
|
1900
|
+
* ack-loss.
|
|
1901
|
+
*/
|
|
1902
|
+
sendInboxAck(entryId) {
|
|
1903
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
1904
|
+
this.ws.send(JSON.stringify({
|
|
1905
|
+
type: "inbox:ack",
|
|
1906
|
+
entryId
|
|
1907
|
+
}));
|
|
1908
|
+
}
|
|
1732
1909
|
async connect() {
|
|
1733
1910
|
this.closing = false;
|
|
1734
1911
|
await this.openWebSocket();
|
|
@@ -1880,6 +2057,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1880
2057
|
this.clearAuthRefreshTimer();
|
|
1881
2058
|
const wasRegistered = this.registered;
|
|
1882
2059
|
this.registered = false;
|
|
2060
|
+
this.wsInboxDeliverActive = false;
|
|
1883
2061
|
this.rejectAllPendingBinds("WebSocket closed");
|
|
1884
2062
|
if (!settled) {
|
|
1885
2063
|
this.wsLogger.warn({ code }, "closed before ready");
|
|
@@ -1911,7 +2089,8 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1911
2089
|
clientId: this.clientId,
|
|
1912
2090
|
hostname: hostname(),
|
|
1913
2091
|
os: platform(),
|
|
1914
|
-
sdkVersion: this.sdkVersion
|
|
2092
|
+
sdkVersion: this.sdkVersion,
|
|
2093
|
+
wireCapabilities: { wsInboxDeliver: WS_INBOX_DELIVER_OPT_IN }
|
|
1915
2094
|
}));
|
|
1916
2095
|
return;
|
|
1917
2096
|
}
|
|
@@ -1921,6 +2100,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1921
2100
|
this.wsLogger.warn({ issues: parsed.error.issues.map((i) => i.message) }, "ignoring malformed server:welcome frame");
|
|
1922
2101
|
return;
|
|
1923
2102
|
}
|
|
2103
|
+
this.wsInboxDeliverActive = parsed.data.capabilities?.wsInboxDeliver === true && WS_INBOX_DELIVER_OPT_IN;
|
|
1924
2104
|
const isReconnect = this.welcomeFramesReceived > 0;
|
|
1925
2105
|
this.welcomeFramesReceived++;
|
|
1926
2106
|
this.emit("server:welcome", {
|
|
@@ -2065,6 +2245,25 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2065
2245
|
else this.emit("agent:message", inboxId, msg);
|
|
2066
2246
|
return;
|
|
2067
2247
|
}
|
|
2248
|
+
if (type === "inbox:deliver") {
|
|
2249
|
+
const parsed = inboxDeliverFrameSchema.safeParse(msg);
|
|
2250
|
+
if (!parsed.success) {
|
|
2251
|
+
this.wsLogger.warn({
|
|
2252
|
+
issues: parsed.error.issues.map((i) => ({
|
|
2253
|
+
path: i.path.join("."),
|
|
2254
|
+
code: i.code,
|
|
2255
|
+
message: i.message
|
|
2256
|
+
})),
|
|
2257
|
+
frameKeys: Object.keys(msg),
|
|
2258
|
+
messageKeys: msg.message && typeof msg.message === "object" ? Object.keys(msg.message) : null
|
|
2259
|
+
}, "malformed inbox:deliver frame — dropping");
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
const emit = () => this.emit("inbox:deliver", parsed.data.inboxId, parsed.data);
|
|
2263
|
+
if (this.pendingImageWrites.size > 0) Promise.all([...this.pendingImageWrites]).finally(emit);
|
|
2264
|
+
else emit();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2068
2267
|
if (type === "error") {
|
|
2069
2268
|
const errorMsg = msg.message;
|
|
2070
2269
|
const ref = msg.ref;
|
|
@@ -2175,13 +2374,23 @@ function getHandlerFactory(type) {
|
|
|
2175
2374
|
}
|
|
2176
2375
|
join(DEFAULT_DATA_DIR, "context-tree");
|
|
2177
2376
|
/**
|
|
2178
|
-
*
|
|
2377
|
+
* Marker file written into every workspace so the Codex CLI's project-root
|
|
2378
|
+
* detection (configured via `project_root_markers: ["first-tree-workspace"]`)
|
|
2379
|
+
* stops at the workspace boundary instead of walking up the filesystem and
|
|
2380
|
+
* loading an unintended `AGENTS.md` from the operator's home or repo root.
|
|
2381
|
+
*/
|
|
2382
|
+
const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
|
|
2383
|
+
/**
|
|
2384
|
+
* Bootstrap a workspace with `.agent/` directory files plus the workspace
|
|
2385
|
+
* root marker (and an optional provider-specific briefing).
|
|
2179
2386
|
*
|
|
2180
|
-
* Writes identity.json, context/
|
|
2181
|
-
*
|
|
2387
|
+
* Writes identity.json, context/agent-instructions.md (if context tree
|
|
2388
|
+
* available), tools.md, the `.first-tree-workspace` marker, and — for
|
|
2389
|
+
* Codex — `AGENTS.md`. Idempotent: safe to call on every handler start()
|
|
2390
|
+
* and on resume().
|
|
2182
2391
|
*/
|
|
2183
2392
|
function bootstrapWorkspace(options) {
|
|
2184
|
-
const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
|
|
2393
|
+
const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
|
|
2185
2394
|
const agentDir = join(workspacePath, ".agent");
|
|
2186
2395
|
const contextDir = join(agentDir, "context");
|
|
2187
2396
|
if (existsSync(contextDir)) rmSync(contextDir, {
|
|
@@ -2207,6 +2416,8 @@ function bootstrapWorkspace(options) {
|
|
|
2207
2416
|
if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
|
|
2208
2417
|
}
|
|
2209
2418
|
writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
|
|
2419
|
+
writeFileSync(join(workspacePath, FIRST_TREE_WORKSPACE_MARKER), "", "utf-8");
|
|
2420
|
+
if (briefing?.format === "agents-md") writeFileSync(join(workspacePath, "AGENTS.md"), briefing.content, "utf-8");
|
|
2210
2421
|
}
|
|
2211
2422
|
function defaultInstallExec(command, args, options) {
|
|
2212
2423
|
execFileSync(command, args, {
|
|
@@ -2932,7 +3143,7 @@ function splitPathExt(pathext) {
|
|
|
2932
3143
|
}
|
|
2933
3144
|
const MAX_RETRIES = 2;
|
|
2934
3145
|
const TOOL_RESULT_PREVIEW_LIMIT = 400;
|
|
2935
|
-
const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
|
|
3146
|
+
const ASSISTANT_TEXT_EVENT_LIMIT$1 = 8e3;
|
|
2936
3147
|
const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
|
|
2937
3148
|
const MIME_TO_EXT = {
|
|
2938
3149
|
"image/png": "png",
|
|
@@ -3058,7 +3269,7 @@ function createToolCallProcessor(emit) {
|
|
|
3058
3269
|
if (text.length === 0) continue;
|
|
3059
3270
|
emit({
|
|
3060
3271
|
kind: "assistant_text",
|
|
3061
|
-
payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
|
|
3272
|
+
payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT$1) }
|
|
3062
3273
|
});
|
|
3063
3274
|
} else if (isThinkingBlock(block)) emit({
|
|
3064
3275
|
kind: "thinking",
|
|
@@ -3602,6 +3813,457 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
|
|
|
3602
3813
|
}
|
|
3603
3814
|
writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
|
|
3604
3815
|
}
|
|
3816
|
+
const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
|
|
3817
|
+
const RESULT_PREVIEW_LIMIT = 400;
|
|
3818
|
+
/**
|
|
3819
|
+
* Build the per-turn `ThreadOptions` Codex consumes. Exported so unit tests
|
|
3820
|
+
* can lock the auth-mode-friendly defaults (notably `model` only set when
|
|
3821
|
+
* the operator chose one).
|
|
3822
|
+
*/
|
|
3823
|
+
function buildCodexThreadOptions(payload, workspaceCwd) {
|
|
3824
|
+
const additionalDirectories = [];
|
|
3825
|
+
for (const repo of payload.gitRepos) {
|
|
3826
|
+
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
3827
|
+
if (!localPath) continue;
|
|
3828
|
+
additionalDirectories.push(join(workspaceCwd, localPath));
|
|
3829
|
+
}
|
|
3830
|
+
const opts = {
|
|
3831
|
+
workingDirectory: workspaceCwd,
|
|
3832
|
+
skipGitRepoCheck: true,
|
|
3833
|
+
sandboxMode: "workspace-write",
|
|
3834
|
+
approvalPolicy: "never",
|
|
3835
|
+
modelReasoningEffort: "high",
|
|
3836
|
+
webSearchEnabled: false,
|
|
3837
|
+
additionalDirectories
|
|
3838
|
+
};
|
|
3839
|
+
if (payload.model) opts.model = payload.model;
|
|
3840
|
+
return opts;
|
|
3841
|
+
}
|
|
3842
|
+
/**
|
|
3843
|
+
* Codex Handler — session-oriented handler using `@openai/codex-sdk`.
|
|
3844
|
+
*
|
|
3845
|
+
* Each instance owns one Thread for one chat. Each turn is a fresh
|
|
3846
|
+
* `runStreamed()` call (Codex CLI is run-to-completion per turn). Inject
|
|
3847
|
+
* during an active turn buffers messages and runs them as a follow-up turn
|
|
3848
|
+
* the moment the current one completes.
|
|
3849
|
+
*
|
|
3850
|
+
* Key footguns observed end-to-end (private plan §10.7):
|
|
3851
|
+
* - F1: providing `env` to Codex SDK does NOT inherit `process.env`; we
|
|
3852
|
+
* explicitly merge.
|
|
3853
|
+
* - F2: `resumeThread(id)` does NOT inherit `ThreadOptions`; we re-pass
|
|
3854
|
+
* them every time.
|
|
3855
|
+
* - F3: `modelReasoningEffort: "minimal"` is incompatible with default
|
|
3856
|
+
* tools; we default to `"high"` with `webSearchEnabled: false`.
|
|
3857
|
+
* - F6: `Thread` has no close/dispose — shutdown is exclusively
|
|
3858
|
+
* `AbortController.abort()`.
|
|
3859
|
+
*/
|
|
3860
|
+
const createCodexHandler = (config) => {
|
|
3861
|
+
const workspaceRoot = config.workspaceRoot;
|
|
3862
|
+
const agentConfigCache = config.agentConfigCache ?? null;
|
|
3863
|
+
const gitMirrorManager = config.gitMirrorManager ?? null;
|
|
3864
|
+
const contextTreePath = config.contextTreePath ?? null;
|
|
3865
|
+
let cwd = null;
|
|
3866
|
+
let codex = null;
|
|
3867
|
+
let thread = null;
|
|
3868
|
+
let threadId = null;
|
|
3869
|
+
let currentAbort = null;
|
|
3870
|
+
let currentTurnPromise = null;
|
|
3871
|
+
let ctx = null;
|
|
3872
|
+
let drainScheduled = false;
|
|
3873
|
+
const queuedMessages = [];
|
|
3874
|
+
const ownedWorktrees = [];
|
|
3875
|
+
function buildEnv(sessionCtx) {
|
|
3876
|
+
const env = {};
|
|
3877
|
+
for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
|
|
3878
|
+
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
3879
|
+
if (payload) for (const e of payload.env) env[e.key] = e.value;
|
|
3880
|
+
const merged = sessionCtx.buildAgentEnv(env);
|
|
3881
|
+
const out = {};
|
|
3882
|
+
for (const [k, v] of Object.entries(merged)) if (typeof v === "string") out[k] = v;
|
|
3883
|
+
return out;
|
|
3884
|
+
}
|
|
3885
|
+
function buildCodexConfig(payload) {
|
|
3886
|
+
const cfg = { project_root_markers: [FIRST_TREE_WORKSPACE_MARKER] };
|
|
3887
|
+
if (payload.mcpServers.length === 0) return cfg;
|
|
3888
|
+
const mcpServers = {};
|
|
3889
|
+
for (const m of payload.mcpServers) if (m.transport === "stdio") mcpServers[m.name] = {
|
|
3890
|
+
command: m.command,
|
|
3891
|
+
args: m.args ?? []
|
|
3892
|
+
};
|
|
3893
|
+
else {
|
|
3894
|
+
const entry = { url: m.url };
|
|
3895
|
+
if (m.headers) entry.headers = m.headers;
|
|
3896
|
+
mcpServers[m.name] = entry;
|
|
3897
|
+
}
|
|
3898
|
+
cfg.mcp_servers = mcpServers;
|
|
3899
|
+
return cfg;
|
|
3900
|
+
}
|
|
3901
|
+
function buildAgentBriefing(payload) {
|
|
3902
|
+
const lines = [];
|
|
3903
|
+
lines.push("# Agent Briefing");
|
|
3904
|
+
lines.push("");
|
|
3905
|
+
if (payload.prompt.append.trim()) {
|
|
3906
|
+
lines.push(payload.prompt.append.trim());
|
|
3907
|
+
lines.push("");
|
|
3908
|
+
}
|
|
3909
|
+
lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
|
|
3910
|
+
lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
|
|
3911
|
+
lines.push("(when configured).");
|
|
3912
|
+
return lines.join("\n").concat("\n");
|
|
3913
|
+
}
|
|
3914
|
+
function toCodexInput(message, sessionCtx) {
|
|
3915
|
+
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
3916
|
+
}
|
|
3917
|
+
async function prepareGitWorktrees(payload, workspaceCwd, chatId) {
|
|
3918
|
+
if (!gitMirrorManager) return;
|
|
3919
|
+
for (const repo of payload.gitRepos) {
|
|
3920
|
+
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
3921
|
+
if (!localPath) continue;
|
|
3922
|
+
const targetPath = join(workspaceCwd, localPath);
|
|
3923
|
+
if (existsSync(targetPath)) continue;
|
|
3924
|
+
try {
|
|
3925
|
+
await gitMirrorManager.ensureMirror(repo.url);
|
|
3926
|
+
await gitMirrorManager.fetchMirror(repo.url);
|
|
3927
|
+
const result = await gitMirrorManager.createWorktree({
|
|
3928
|
+
url: repo.url,
|
|
3929
|
+
ref: repo.ref,
|
|
3930
|
+
targetPath,
|
|
3931
|
+
sessionKey: chatId
|
|
3932
|
+
});
|
|
3933
|
+
ownedWorktrees.push({
|
|
3934
|
+
url: repo.url,
|
|
3935
|
+
path: targetPath,
|
|
3936
|
+
branchName: result.branchName
|
|
3937
|
+
});
|
|
3938
|
+
} catch (err) {
|
|
3939
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3940
|
+
ctx?.log(`codex git materialisation skipped (${repo.url}): ${msg}`);
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
function emitToolCall(sessionCtx, payload) {
|
|
3945
|
+
const event = {
|
|
3946
|
+
kind: "tool_call",
|
|
3947
|
+
payload: {
|
|
3948
|
+
toolUseId: payload.toolUseId,
|
|
3949
|
+
name: payload.name,
|
|
3950
|
+
args: payload.args,
|
|
3951
|
+
status: payload.status,
|
|
3952
|
+
...payload.resultPreview ? { resultPreview: payload.resultPreview.slice(0, RESULT_PREVIEW_LIMIT) } : {}
|
|
3953
|
+
}
|
|
3954
|
+
};
|
|
3955
|
+
sessionCtx.emitEvent(event);
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Translate one terminal `item.completed` payload into the runtime's event
|
|
3959
|
+
* stream and, when the item is the assistant's final message, return the
|
|
3960
|
+
* raw text so `runTurn` can stitch the per-turn reply together.
|
|
3961
|
+
*/
|
|
3962
|
+
function processItem(item, sessionCtx) {
|
|
3963
|
+
switch (item.type) {
|
|
3964
|
+
case "agent_message":
|
|
3965
|
+
if (!item.text.trim()) return "";
|
|
3966
|
+
sessionCtx.emitEvent({
|
|
3967
|
+
kind: "assistant_text",
|
|
3968
|
+
payload: { text: item.text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
|
|
3969
|
+
});
|
|
3970
|
+
return item.text;
|
|
3971
|
+
case "command_execution": {
|
|
3972
|
+
const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
|
|
3973
|
+
emitToolCall(sessionCtx, {
|
|
3974
|
+
toolUseId: item.id,
|
|
3975
|
+
name: "command",
|
|
3976
|
+
args: { command: item.command },
|
|
3977
|
+
status,
|
|
3978
|
+
resultPreview: item.aggregated_output
|
|
3979
|
+
});
|
|
3980
|
+
return "";
|
|
3981
|
+
}
|
|
3982
|
+
case "file_change": {
|
|
3983
|
+
const status = item.status === "completed" ? "ok" : "error";
|
|
3984
|
+
emitToolCall(sessionCtx, {
|
|
3985
|
+
toolUseId: item.id,
|
|
3986
|
+
name: "file_change",
|
|
3987
|
+
args: { changes: item.changes },
|
|
3988
|
+
status
|
|
3989
|
+
});
|
|
3990
|
+
return "";
|
|
3991
|
+
}
|
|
3992
|
+
case "mcp_tool_call": {
|
|
3993
|
+
const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
|
|
3994
|
+
const resultPreview = item.error ? `error: ${item.error.message}` : item.result ? JSON.stringify(item.result.structured_content ?? item.result.content) : void 0;
|
|
3995
|
+
emitToolCall(sessionCtx, {
|
|
3996
|
+
toolUseId: item.id,
|
|
3997
|
+
name: `mcp:${item.server}/${item.tool}`,
|
|
3998
|
+
args: item.arguments,
|
|
3999
|
+
status,
|
|
4000
|
+
resultPreview
|
|
4001
|
+
});
|
|
4002
|
+
return "";
|
|
4003
|
+
}
|
|
4004
|
+
case "web_search":
|
|
4005
|
+
emitToolCall(sessionCtx, {
|
|
4006
|
+
toolUseId: item.id,
|
|
4007
|
+
name: "web_search",
|
|
4008
|
+
args: { query: item.query },
|
|
4009
|
+
status: "ok"
|
|
4010
|
+
});
|
|
4011
|
+
return "";
|
|
4012
|
+
case "todo_list":
|
|
4013
|
+
emitToolCall(sessionCtx, {
|
|
4014
|
+
toolUseId: item.id,
|
|
4015
|
+
name: "todo_list",
|
|
4016
|
+
args: { items: item.items },
|
|
4017
|
+
status: "ok"
|
|
4018
|
+
});
|
|
4019
|
+
return "";
|
|
4020
|
+
case "reasoning":
|
|
4021
|
+
sessionCtx.emitEvent({
|
|
4022
|
+
kind: "thinking",
|
|
4023
|
+
payload: {}
|
|
4024
|
+
});
|
|
4025
|
+
return "";
|
|
4026
|
+
case "error":
|
|
4027
|
+
sessionCtx.emitEvent({
|
|
4028
|
+
kind: "error",
|
|
4029
|
+
payload: {
|
|
4030
|
+
source: "tool",
|
|
4031
|
+
message: item.message
|
|
4032
|
+
}
|
|
4033
|
+
});
|
|
4034
|
+
return "";
|
|
4035
|
+
default: return "";
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
async function runTurn(input, sessionCtx) {
|
|
4039
|
+
const activeThread = thread;
|
|
4040
|
+
if (!activeThread) return;
|
|
4041
|
+
const abort = new AbortController();
|
|
4042
|
+
currentAbort = abort;
|
|
4043
|
+
sessionCtx.setRuntimeState("working");
|
|
4044
|
+
const assistantTexts = [];
|
|
4045
|
+
let turnFailed = false;
|
|
4046
|
+
const promise = (async () => {
|
|
4047
|
+
try {
|
|
4048
|
+
const streamed = await activeThread.runStreamed(input, { signal: abort.signal });
|
|
4049
|
+
for await (const event of streamed.events) {
|
|
4050
|
+
if (abort.signal.aborted) break;
|
|
4051
|
+
sessionCtx.touch();
|
|
4052
|
+
if (event.type === "thread.started") threadId = event.thread_id;
|
|
4053
|
+
else if (event.type === "turn.started") {} else if (event.type === "item.completed") {
|
|
4054
|
+
const text = processItem(event.item, sessionCtx);
|
|
4055
|
+
if (text) assistantTexts.push(text);
|
|
4056
|
+
} else if (event.type === "item.started" || event.type === "item.updated") {} else if (event.type === "turn.completed") {} else if (event.type === "turn.failed") {
|
|
4057
|
+
turnFailed = true;
|
|
4058
|
+
sessionCtx.emitEvent({
|
|
4059
|
+
kind: "error",
|
|
4060
|
+
payload: {
|
|
4061
|
+
source: "sdk",
|
|
4062
|
+
message: event.error.message
|
|
4063
|
+
}
|
|
4064
|
+
});
|
|
4065
|
+
} else if (event.type === "error") sessionCtx.emitEvent({
|
|
4066
|
+
kind: "error",
|
|
4067
|
+
payload: {
|
|
4068
|
+
source: "sdk",
|
|
4069
|
+
message: event.message
|
|
4070
|
+
}
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
4073
|
+
} catch (err) {
|
|
4074
|
+
if (abort.signal.aborted) return;
|
|
4075
|
+
turnFailed = true;
|
|
4076
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4077
|
+
sessionCtx.emitEvent({
|
|
4078
|
+
kind: "error",
|
|
4079
|
+
payload: {
|
|
4080
|
+
source: "sdk",
|
|
4081
|
+
message: msg
|
|
4082
|
+
}
|
|
4083
|
+
});
|
|
4084
|
+
}
|
|
4085
|
+
})();
|
|
4086
|
+
currentTurnPromise = promise;
|
|
4087
|
+
try {
|
|
4088
|
+
await promise;
|
|
4089
|
+
} finally {
|
|
4090
|
+
currentAbort = null;
|
|
4091
|
+
currentTurnPromise = null;
|
|
4092
|
+
}
|
|
4093
|
+
if (abort.signal.aborted) return;
|
|
4094
|
+
const accumulated = assistantTexts.join("\n\n");
|
|
4095
|
+
let forwardFailed = false;
|
|
4096
|
+
if (accumulated.trim()) try {
|
|
4097
|
+
await sessionCtx.forwardResult(accumulated);
|
|
4098
|
+
} catch (err) {
|
|
4099
|
+
forwardFailed = true;
|
|
4100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4101
|
+
sessionCtx.emitEvent({
|
|
4102
|
+
kind: "error",
|
|
4103
|
+
payload: {
|
|
4104
|
+
source: "runtime",
|
|
4105
|
+
message: `forwardResult failed: ${msg}`
|
|
4106
|
+
}
|
|
4107
|
+
});
|
|
4108
|
+
}
|
|
4109
|
+
const succeeded = !turnFailed && !forwardFailed;
|
|
4110
|
+
sessionCtx.emitEvent({
|
|
4111
|
+
kind: "turn_end",
|
|
4112
|
+
payload: { status: succeeded ? "success" : "error" }
|
|
4113
|
+
});
|
|
4114
|
+
if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
|
|
4115
|
+
sessionCtx.setRuntimeState("idle");
|
|
4116
|
+
if (queuedMessages.length > 0 && !drainScheduled) {
|
|
4117
|
+
drainScheduled = true;
|
|
4118
|
+
setImmediate(() => {
|
|
4119
|
+
drainScheduled = false;
|
|
4120
|
+
const drained = queuedMessages.splice(0);
|
|
4121
|
+
if (drained.length === 0 || !ctx || !thread) return;
|
|
4122
|
+
mergeAndRun(drained, ctx);
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
async function mergeAndRun(drained, sessionCtx) {
|
|
4127
|
+
const inputs = [];
|
|
4128
|
+
for (const m of drained) try {
|
|
4129
|
+
inputs.push(await sessionCtx.formatInboundContent(m));
|
|
4130
|
+
} catch (err) {
|
|
4131
|
+
sessionCtx.log(`codex inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4132
|
+
}
|
|
4133
|
+
if (inputs.length === 0) return;
|
|
4134
|
+
await runTurn(inputs.join("\n\n"), sessionCtx);
|
|
4135
|
+
}
|
|
4136
|
+
return {
|
|
4137
|
+
async start(message, sessionCtx) {
|
|
4138
|
+
ctx = sessionCtx;
|
|
4139
|
+
cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4140
|
+
let payload = null;
|
|
4141
|
+
if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
|
|
4142
|
+
if (!payload) payload = {
|
|
4143
|
+
kind: "codex",
|
|
4144
|
+
prompt: { append: "" },
|
|
4145
|
+
model: "",
|
|
4146
|
+
mcpServers: [],
|
|
4147
|
+
env: [],
|
|
4148
|
+
gitRepos: []
|
|
4149
|
+
};
|
|
4150
|
+
bootstrapWorkspace({
|
|
4151
|
+
workspacePath: cwd,
|
|
4152
|
+
identity: sessionCtx.agent,
|
|
4153
|
+
contextTreePath,
|
|
4154
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
4155
|
+
chatId: sessionCtx.chatId,
|
|
4156
|
+
briefing: {
|
|
4157
|
+
format: "agents-md",
|
|
4158
|
+
content: buildAgentBriefing(payload)
|
|
4159
|
+
}
|
|
4160
|
+
});
|
|
4161
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
|
|
4162
|
+
codex = new Codex({
|
|
4163
|
+
env: buildEnv(sessionCtx),
|
|
4164
|
+
config: buildCodexConfig(payload)
|
|
4165
|
+
});
|
|
4166
|
+
thread = codex.startThread(buildCodexThreadOptions(payload, cwd));
|
|
4167
|
+
await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4168
|
+
if (!threadId) threadId = thread.id ?? null;
|
|
4169
|
+
if (!threadId) throw new Error("codex did not assign a thread id during the first turn");
|
|
4170
|
+
return threadId;
|
|
4171
|
+
},
|
|
4172
|
+
async resume(message, sessionId, sessionCtx) {
|
|
4173
|
+
ctx = sessionCtx;
|
|
4174
|
+
cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
4175
|
+
let payload = null;
|
|
4176
|
+
if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
|
|
4177
|
+
if (!payload) payload = {
|
|
4178
|
+
kind: "codex",
|
|
4179
|
+
prompt: { append: "" },
|
|
4180
|
+
model: "",
|
|
4181
|
+
mcpServers: [],
|
|
4182
|
+
env: [],
|
|
4183
|
+
gitRepos: []
|
|
4184
|
+
};
|
|
4185
|
+
bootstrapWorkspace({
|
|
4186
|
+
workspacePath: cwd,
|
|
4187
|
+
identity: sessionCtx.agent,
|
|
4188
|
+
contextTreePath,
|
|
4189
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
4190
|
+
chatId: sessionCtx.chatId,
|
|
4191
|
+
briefing: {
|
|
4192
|
+
format: "agents-md",
|
|
4193
|
+
content: buildAgentBriefing(payload)
|
|
4194
|
+
}
|
|
4195
|
+
});
|
|
4196
|
+
await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
|
|
4197
|
+
codex = new Codex({
|
|
4198
|
+
env: buildEnv(sessionCtx),
|
|
4199
|
+
config: buildCodexConfig(payload)
|
|
4200
|
+
});
|
|
4201
|
+
thread = codex.resumeThread(sessionId, buildCodexThreadOptions(payload, cwd));
|
|
4202
|
+
threadId = sessionId;
|
|
4203
|
+
if (message) await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4204
|
+
return sessionId;
|
|
4205
|
+
},
|
|
4206
|
+
inject(message) {
|
|
4207
|
+
if (currentTurnPromise) {
|
|
4208
|
+
queuedMessages.push(message);
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
const sessionCtx = ctx;
|
|
4212
|
+
if (!sessionCtx || !thread) return;
|
|
4213
|
+
(async () => {
|
|
4214
|
+
try {
|
|
4215
|
+
await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
|
|
4216
|
+
} catch (err) {
|
|
4217
|
+
sessionCtx.log(`codex inject failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4218
|
+
}
|
|
4219
|
+
})();
|
|
4220
|
+
},
|
|
4221
|
+
async suspend() {
|
|
4222
|
+
currentAbort?.abort();
|
|
4223
|
+
try {
|
|
4224
|
+
await currentTurnPromise;
|
|
4225
|
+
} catch {}
|
|
4226
|
+
currentAbort = null;
|
|
4227
|
+
currentTurnPromise = null;
|
|
4228
|
+
thread = null;
|
|
4229
|
+
codex = null;
|
|
4230
|
+
},
|
|
4231
|
+
async shutdown() {
|
|
4232
|
+
currentAbort?.abort();
|
|
4233
|
+
try {
|
|
4234
|
+
await currentTurnPromise;
|
|
4235
|
+
} catch {}
|
|
4236
|
+
currentAbort = null;
|
|
4237
|
+
currentTurnPromise = null;
|
|
4238
|
+
thread = null;
|
|
4239
|
+
codex = null;
|
|
4240
|
+
if (gitMirrorManager) {
|
|
4241
|
+
for (const wt of ownedWorktrees) try {
|
|
4242
|
+
await gitMirrorManager.removeWorktree({
|
|
4243
|
+
url: wt.url,
|
|
4244
|
+
path: wt.path,
|
|
4245
|
+
branchName: wt.branchName
|
|
4246
|
+
});
|
|
4247
|
+
} catch (err) {
|
|
4248
|
+
ctx?.log(`codex worktree cleanup failed (${wt.path}): ${err instanceof Error ? err.message : String(err)}`);
|
|
4249
|
+
}
|
|
4250
|
+
ownedWorktrees.length = 0;
|
|
4251
|
+
}
|
|
4252
|
+
if (cwd && existsSync(cwd)) try {
|
|
4253
|
+
rmSync(cwd, {
|
|
4254
|
+
recursive: true,
|
|
4255
|
+
force: true
|
|
4256
|
+
});
|
|
4257
|
+
} catch (err) {
|
|
4258
|
+
ctx?.log(`codex workspace cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4259
|
+
}
|
|
4260
|
+
cwd = null;
|
|
4261
|
+
threadId = null;
|
|
4262
|
+
ctx = null;
|
|
4263
|
+
queuedMessages.length = 0;
|
|
4264
|
+
}
|
|
4265
|
+
};
|
|
4266
|
+
};
|
|
3605
4267
|
/** Register all built-in handlers. Call once at startup. */
|
|
3606
4268
|
function registerBuiltinHandlers() {
|
|
3607
4269
|
const resolution = resolveClaudeCodeExecutable();
|
|
@@ -3611,6 +4273,7 @@ function registerBuiltinHandlers() {
|
|
|
3611
4273
|
...config,
|
|
3612
4274
|
claudeCodeExecutable: resolution.path
|
|
3613
4275
|
}));
|
|
4276
|
+
registerHandler("codex", (config) => createCodexHandler(config));
|
|
3614
4277
|
}
|
|
3615
4278
|
function createAgentConfigCache(opts) {
|
|
3616
4279
|
const { sdk } = opts;
|
|
@@ -3645,6 +4308,7 @@ function createAgentConfigCache(opts) {
|
|
|
3645
4308
|
agentId,
|
|
3646
4309
|
version: 0,
|
|
3647
4310
|
payload: {
|
|
4311
|
+
kind: "claude-code",
|
|
3648
4312
|
prompt: { append: "" },
|
|
3649
4313
|
model: "",
|
|
3650
4314
|
mcpServers: [],
|
|
@@ -4325,11 +4989,19 @@ var SessionManager = class {
|
|
|
4325
4989
|
this.lastReportedStates.set(chatId, state);
|
|
4326
4990
|
this.config.onStateChange(chatId, state);
|
|
4327
4991
|
}
|
|
4328
|
-
/**
|
|
4992
|
+
/**
|
|
4993
|
+
* ACK an inbox entry — delayed until handler starts processing.
|
|
4994
|
+
*
|
|
4995
|
+
* Routes through `config.ackEntry` when set (WS push path) or falls back to
|
|
4996
|
+
* `sdk.ack` (HTTP poll path). One ack per entry, one channel per slot —
|
|
4997
|
+
* mixing channels in one slot would leak the server's per-agent in-flight
|
|
4998
|
+
* counter (proposal hub-inbox-ws-data-plane §3.5).
|
|
4999
|
+
*/
|
|
4329
5000
|
async ackEntry(entryId, chatId) {
|
|
4330
5001
|
if (entryId === void 0) return;
|
|
4331
5002
|
try {
|
|
4332
|
-
await this.config.
|
|
5003
|
+
if (this.config.ackEntry) await this.config.ackEntry(entryId);
|
|
5004
|
+
else await this.config.sdk.ack(entryId);
|
|
4333
5005
|
} catch {
|
|
4334
5006
|
this.config.log.warn({
|
|
4335
5007
|
chatId,
|
|
@@ -4480,6 +5152,12 @@ var AgentSlot = class {
|
|
|
4480
5152
|
pollingTimer = null;
|
|
4481
5153
|
reconcileTimer = null;
|
|
4482
5154
|
listeners = [];
|
|
5155
|
+
/**
|
|
5156
|
+
* The inbox this slot's agent owns — used to filter `inbox:deliver`
|
|
5157
|
+
* frames addressed to other agents on the same client. Captured at
|
|
5158
|
+
* `start()` from `sdk.register()`.
|
|
5159
|
+
*/
|
|
5160
|
+
inboxId = null;
|
|
4483
5161
|
constructor(config) {
|
|
4484
5162
|
this.config = config;
|
|
4485
5163
|
this.logger = createLogger("slot").child({
|
|
@@ -4522,9 +5200,19 @@ var AgentSlot = class {
|
|
|
4522
5200
|
this.logger.error({ err }, "failed to fetch agent config — bind aborted");
|
|
4523
5201
|
throw new Error(`Hub unreachable while loading agent config: ${msg}`);
|
|
4524
5202
|
}
|
|
5203
|
+
this.inboxId = agent.inboxId;
|
|
4525
5204
|
const onMessage = (agentId) => {
|
|
4526
5205
|
if (agentId === this.config.agentId) this.pullAndDispatch();
|
|
4527
5206
|
};
|
|
5207
|
+
const onInboxDeliver = (inboxId, frame) => {
|
|
5208
|
+
if (inboxId !== this.inboxId) return;
|
|
5209
|
+
this.dispatchPushedFrame(frame).catch((err) => {
|
|
5210
|
+
this.logger.warn({
|
|
5211
|
+
err,
|
|
5212
|
+
entryId: frame.entryId
|
|
5213
|
+
}, "inbox:deliver dispatch error");
|
|
5214
|
+
});
|
|
5215
|
+
};
|
|
4528
5216
|
const onBound = (boundAgent) => {
|
|
4529
5217
|
if (boundAgent.agentId === this.config.agentId) {
|
|
4530
5218
|
this.fullStateSync();
|
|
@@ -4535,11 +5223,15 @@ var AgentSlot = class {
|
|
|
4535
5223
|
if (result.agentId === this.config.agentId && this.sessionManager) this.sessionManager.applyStaleChatIds(result.staleChatIds);
|
|
4536
5224
|
};
|
|
4537
5225
|
this.clientConnection.on("agent:message", onMessage);
|
|
5226
|
+
this.clientConnection.on("inbox:deliver", onInboxDeliver);
|
|
4538
5227
|
this.clientConnection.on("agent:bound", onBound);
|
|
4539
5228
|
this.clientConnection.on("session:reconcile:result", onReconcileResult);
|
|
4540
5229
|
this.listeners.push({
|
|
4541
5230
|
event: "agent:message",
|
|
4542
5231
|
fn: onMessage
|
|
5232
|
+
}, {
|
|
5233
|
+
event: "inbox:deliver",
|
|
5234
|
+
fn: onInboxDeliver
|
|
4543
5235
|
}, {
|
|
4544
5236
|
event: "agent:bound",
|
|
4545
5237
|
fn: onBound
|
|
@@ -4555,6 +5247,10 @@ var AgentSlot = class {
|
|
|
4555
5247
|
agentId: this.config.agentId
|
|
4556
5248
|
})
|
|
4557
5249
|
});
|
|
5250
|
+
const ackEntry = this.clientConnection.supportsWsInboxDeliver ? (entryId) => {
|
|
5251
|
+
this.clientConnection.sendInboxAck(entryId);
|
|
5252
|
+
return Promise.resolve();
|
|
5253
|
+
} : void 0;
|
|
4558
5254
|
this.sessionManager = new SessionManager({
|
|
4559
5255
|
session: this.config.session,
|
|
4560
5256
|
concurrency: this.config.concurrency,
|
|
@@ -4576,6 +5272,7 @@ var AgentSlot = class {
|
|
|
4576
5272
|
log: this.logger,
|
|
4577
5273
|
registryPath,
|
|
4578
5274
|
agentConfigCache: this.agentConfigCache,
|
|
5275
|
+
ackEntry,
|
|
4579
5276
|
onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
|
|
4580
5277
|
onRuntimeStateChange: (state) => this.reportRuntimeState(state),
|
|
4581
5278
|
onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event),
|
|
@@ -4609,6 +5306,7 @@ var AgentSlot = class {
|
|
|
4609
5306
|
this.reconcileTimer = null;
|
|
4610
5307
|
}
|
|
4611
5308
|
for (const entry of this.listeners) if (entry.event === "agent:message") this.clientConnection.off(entry.event, entry.fn);
|
|
5309
|
+
else if (entry.event === "inbox:deliver") this.clientConnection.off(entry.event, entry.fn);
|
|
4612
5310
|
else if (entry.event === "agent:bound") this.clientConnection.off(entry.event, entry.fn);
|
|
4613
5311
|
else if (entry.event === "session:reconcile:result") this.clientConnection.off(entry.event, entry.fn);
|
|
4614
5312
|
else this.clientConnection.off(entry.event, entry.fn);
|
|
@@ -4636,11 +5334,47 @@ var AgentSlot = class {
|
|
|
4636
5334
|
if (runtimeState) this.clientConnection.reportRuntimeState(this.config.agentId, runtimeState);
|
|
4637
5335
|
}
|
|
4638
5336
|
startPolling() {
|
|
5337
|
+
if (this.clientConnection.supportsWsInboxDeliver) {
|
|
5338
|
+
this.logger.info("WS inbox data plane active — skipping 5s HTTP poll");
|
|
5339
|
+
return;
|
|
5340
|
+
}
|
|
4639
5341
|
this.pollingTimer = setInterval(() => {
|
|
4640
5342
|
this.pullAndDispatch();
|
|
4641
5343
|
}, 5e3);
|
|
4642
5344
|
this.pullAndDispatch();
|
|
4643
5345
|
}
|
|
5346
|
+
/**
|
|
5347
|
+
* Translate an `inbox:deliver` push frame into the {@link InboxEntryWithMessage}
|
|
5348
|
+
* shape `SessionManager.dispatch` expects, then dispatch.
|
|
5349
|
+
*
|
|
5350
|
+
* Ack happens INSIDE `dispatch` via the `ackEntry` callback we pinned at
|
|
5351
|
+
* construction time — for push slots that's `clientConnection.sendInboxAck`,
|
|
5352
|
+
* for poll slots it stays the legacy `sdk.ack`. Sending an additional ack
|
|
5353
|
+
* here would double-ack: HTTP first (`delivered → acked`) followed by a
|
|
5354
|
+
* WS frame the server can no longer match against any `delivered` row,
|
|
5355
|
+
* which leaks the server's per-agent in-flight counter and stalls push
|
|
5356
|
+
* after `inboxMaxInFlightPerAgent` messages.
|
|
5357
|
+
*
|
|
5358
|
+
* Dispatch errors propagate up; the entry stays `delivered` server-side
|
|
5359
|
+
* and the 300s timeout reaper rolls it back to `pending` for replay
|
|
5360
|
+
* (proposal §3.7).
|
|
5361
|
+
*/
|
|
5362
|
+
async dispatchPushedFrame(frame) {
|
|
5363
|
+
if (!this.sessionManager) return;
|
|
5364
|
+
const entry = {
|
|
5365
|
+
id: frame.entryId,
|
|
5366
|
+
inboxId: frame.inboxId,
|
|
5367
|
+
messageId: frame.message.id,
|
|
5368
|
+
chatId: frame.chatId,
|
|
5369
|
+
status: "delivered",
|
|
5370
|
+
retryCount: 0,
|
|
5371
|
+
createdAt: frame.message.createdAt,
|
|
5372
|
+
deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5373
|
+
ackedAt: null,
|
|
5374
|
+
message: frame.message
|
|
5375
|
+
};
|
|
5376
|
+
await this.sessionManager.dispatch(entry);
|
|
5377
|
+
}
|
|
4644
5378
|
startReconcileLoop() {
|
|
4645
5379
|
const intervalSec = this.config.session.reconcile_interval_seconds ?? 300;
|
|
4646
5380
|
this.reconcileTimer = setInterval(() => this.reconcileNow(), intervalSec * 1e3);
|
|
@@ -4662,6 +5396,226 @@ var AgentSlot = class {
|
|
|
4662
5396
|
}
|
|
4663
5397
|
};
|
|
4664
5398
|
/**
|
|
5399
|
+
* Top-level marker file Claude Code writes after a successful OAuth login.
|
|
5400
|
+
* Path is platform-agnostic (`~/.claude.json`); the access token itself lives
|
|
5401
|
+
* in the platform credential store (macOS Keychain entry "Claude Code-
|
|
5402
|
+
* credentials", or libsecret on Linux), so we treat the presence of an
|
|
5403
|
+
* `oauthAccount.accountUuid` field as the canonical "logged in" signal.
|
|
5404
|
+
*/
|
|
5405
|
+
const CLAUDE_PROFILE_PATH = () => join(homedir(), ".claude.json");
|
|
5406
|
+
function hasClaudeOAuthAccount() {
|
|
5407
|
+
try {
|
|
5408
|
+
const path = CLAUDE_PROFILE_PATH();
|
|
5409
|
+
if (!existsSync(path)) return false;
|
|
5410
|
+
const raw = readFileSync(path, "utf-8");
|
|
5411
|
+
const obj = JSON.parse(raw);
|
|
5412
|
+
return typeof obj.oauthAccount?.accountUuid === "string" && obj.oauthAccount.accountUuid.length > 0;
|
|
5413
|
+
} catch {
|
|
5414
|
+
return false;
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
async function readSdkVersion$1() {
|
|
5418
|
+
try {
|
|
5419
|
+
let dir = dirname(fileURLToPath(await import.meta.resolve("@anthropic-ai/claude-agent-sdk")));
|
|
5420
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
5421
|
+
const candidate = join(dir, "package.json");
|
|
5422
|
+
if (existsSync(candidate)) {
|
|
5423
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
5424
|
+
if (pkg.name === "@anthropic-ai/claude-agent-sdk" && typeof pkg.version === "string") return pkg.version;
|
|
5425
|
+
}
|
|
5426
|
+
const parent = dirname(dir);
|
|
5427
|
+
if (parent === dir) break;
|
|
5428
|
+
dir = parent;
|
|
5429
|
+
}
|
|
5430
|
+
} catch {}
|
|
5431
|
+
return null;
|
|
5432
|
+
}
|
|
5433
|
+
function detectAuth$1() {
|
|
5434
|
+
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.length > 0) return {
|
|
5435
|
+
authenticated: true,
|
|
5436
|
+
method: "api_key"
|
|
5437
|
+
};
|
|
5438
|
+
if (hasClaudeOAuthAccount()) return {
|
|
5439
|
+
authenticated: true,
|
|
5440
|
+
method: "oauth"
|
|
5441
|
+
};
|
|
5442
|
+
return {
|
|
5443
|
+
authenticated: false,
|
|
5444
|
+
method: "none"
|
|
5445
|
+
};
|
|
5446
|
+
}
|
|
5447
|
+
/**
|
|
5448
|
+
* Probe whether the Claude Code runtime is usable on this machine.
|
|
5449
|
+
*
|
|
5450
|
+
* `state` is the authoritative field; `available` and `authenticated` are
|
|
5451
|
+
* derived booleans kept around for simple consumers (e.g. capability lookup
|
|
5452
|
+
* in service-layer guards).
|
|
5453
|
+
*/
|
|
5454
|
+
async function probeClaudeCodeCapability() {
|
|
5455
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5456
|
+
try {
|
|
5457
|
+
let sdkPresent = false;
|
|
5458
|
+
try {
|
|
5459
|
+
await import("@anthropic-ai/claude-agent-sdk");
|
|
5460
|
+
sdkPresent = true;
|
|
5461
|
+
} catch {
|
|
5462
|
+
sdkPresent = false;
|
|
5463
|
+
}
|
|
5464
|
+
if (!sdkPresent) return {
|
|
5465
|
+
state: "missing",
|
|
5466
|
+
available: false,
|
|
5467
|
+
authenticated: false,
|
|
5468
|
+
sdkVersion: null,
|
|
5469
|
+
authMethod: "none",
|
|
5470
|
+
detectedAt
|
|
5471
|
+
};
|
|
5472
|
+
const sdkVersion = await readSdkVersion$1();
|
|
5473
|
+
const auth = detectAuth$1();
|
|
5474
|
+
if (!auth.authenticated) return {
|
|
5475
|
+
state: "unauthenticated",
|
|
5476
|
+
available: true,
|
|
5477
|
+
authenticated: false,
|
|
5478
|
+
sdkVersion,
|
|
5479
|
+
authMethod: "none",
|
|
5480
|
+
detectedAt
|
|
5481
|
+
};
|
|
5482
|
+
return {
|
|
5483
|
+
state: "ok",
|
|
5484
|
+
available: true,
|
|
5485
|
+
authenticated: true,
|
|
5486
|
+
sdkVersion,
|
|
5487
|
+
authMethod: auth.method,
|
|
5488
|
+
detectedAt
|
|
5489
|
+
};
|
|
5490
|
+
} catch (err) {
|
|
5491
|
+
return {
|
|
5492
|
+
state: "error",
|
|
5493
|
+
available: false,
|
|
5494
|
+
authenticated: false,
|
|
5495
|
+
sdkVersion: null,
|
|
5496
|
+
authMethod: "none",
|
|
5497
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5498
|
+
detectedAt
|
|
5499
|
+
};
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
function codexAuthPath() {
|
|
5503
|
+
return join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
|
|
5504
|
+
}
|
|
5505
|
+
async function readSdkVersion() {
|
|
5506
|
+
try {
|
|
5507
|
+
let dir = dirname(fileURLToPath(await import.meta.resolve("@openai/codex-sdk")));
|
|
5508
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
5509
|
+
const candidate = join(dir, "package.json");
|
|
5510
|
+
if (existsSync(candidate)) {
|
|
5511
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
5512
|
+
if (pkg.name === "@openai/codex-sdk" && typeof pkg.version === "string") return pkg.version;
|
|
5513
|
+
}
|
|
5514
|
+
const parent = dirname(dir);
|
|
5515
|
+
if (parent === dir) break;
|
|
5516
|
+
dir = parent;
|
|
5517
|
+
}
|
|
5518
|
+
} catch {}
|
|
5519
|
+
return null;
|
|
5520
|
+
}
|
|
5521
|
+
function detectAuth() {
|
|
5522
|
+
if (process.env.CODEX_API_KEY && process.env.CODEX_API_KEY.length > 0) return {
|
|
5523
|
+
authenticated: true,
|
|
5524
|
+
method: "api_key"
|
|
5525
|
+
};
|
|
5526
|
+
if (existsSync(codexAuthPath())) return {
|
|
5527
|
+
authenticated: true,
|
|
5528
|
+
method: "auth_json"
|
|
5529
|
+
};
|
|
5530
|
+
return {
|
|
5531
|
+
authenticated: false,
|
|
5532
|
+
method: "none"
|
|
5533
|
+
};
|
|
5534
|
+
}
|
|
5535
|
+
/**
|
|
5536
|
+
* Probe whether the OpenAI Codex runtime is usable on this machine.
|
|
5537
|
+
* Treats `~/.codex/auth.json` (set by `codex login`) as the canonical local
|
|
5538
|
+
* auth source; CODEX_API_KEY env shortcuts that for ephemeral use.
|
|
5539
|
+
*/
|
|
5540
|
+
async function probeCodexCapability() {
|
|
5541
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5542
|
+
try {
|
|
5543
|
+
let sdkPresent = false;
|
|
5544
|
+
try {
|
|
5545
|
+
await import("@openai/codex-sdk");
|
|
5546
|
+
sdkPresent = true;
|
|
5547
|
+
} catch {
|
|
5548
|
+
sdkPresent = false;
|
|
5549
|
+
}
|
|
5550
|
+
if (!sdkPresent) return {
|
|
5551
|
+
state: "missing",
|
|
5552
|
+
available: false,
|
|
5553
|
+
authenticated: false,
|
|
5554
|
+
sdkVersion: null,
|
|
5555
|
+
authMethod: "none",
|
|
5556
|
+
detectedAt
|
|
5557
|
+
};
|
|
5558
|
+
const sdkVersion = await readSdkVersion();
|
|
5559
|
+
const auth = detectAuth();
|
|
5560
|
+
if (!auth.authenticated) return {
|
|
5561
|
+
state: "unauthenticated",
|
|
5562
|
+
available: true,
|
|
5563
|
+
authenticated: false,
|
|
5564
|
+
sdkVersion,
|
|
5565
|
+
authMethod: "none",
|
|
5566
|
+
detectedAt
|
|
5567
|
+
};
|
|
5568
|
+
return {
|
|
5569
|
+
state: "ok",
|
|
5570
|
+
available: true,
|
|
5571
|
+
authenticated: true,
|
|
5572
|
+
sdkVersion,
|
|
5573
|
+
authMethod: auth.method,
|
|
5574
|
+
detectedAt
|
|
5575
|
+
};
|
|
5576
|
+
} catch (err) {
|
|
5577
|
+
return {
|
|
5578
|
+
state: "error",
|
|
5579
|
+
available: false,
|
|
5580
|
+
authenticated: false,
|
|
5581
|
+
sdkVersion: null,
|
|
5582
|
+
authMethod: "none",
|
|
5583
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5584
|
+
detectedAt
|
|
5585
|
+
};
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Run every built-in capability probe and aggregate the results.
|
|
5590
|
+
*
|
|
5591
|
+
* Each provider gets its own module under this directory; the orchestrator
|
|
5592
|
+
* is intentionally simple — adding a new provider means importing the new
|
|
5593
|
+
* probe here and registering its key. The probe modules themselves are
|
|
5594
|
+
* deliberately not part of the `HandlerFactory` interface so capability
|
|
5595
|
+
* detection stays decoupled from runtime instantiation (so we can probe
|
|
5596
|
+
* whether a runtime is usable before spawning anything).
|
|
5597
|
+
*/
|
|
5598
|
+
async function probeCapabilities() {
|
|
5599
|
+
const probes = [["claude-code", probeClaudeCodeCapability()], ["codex", probeCodexCapability()]];
|
|
5600
|
+
const out = {};
|
|
5601
|
+
await Promise.all(probes.map(async ([provider, p]) => {
|
|
5602
|
+
try {
|
|
5603
|
+
out[provider] = await p;
|
|
5604
|
+
} catch (err) {
|
|
5605
|
+
out[provider] = {
|
|
5606
|
+
state: "error",
|
|
5607
|
+
available: false,
|
|
5608
|
+
authenticated: false,
|
|
5609
|
+
sdkVersion: null,
|
|
5610
|
+
authMethod: "none",
|
|
5611
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5612
|
+
detectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5613
|
+
};
|
|
5614
|
+
}
|
|
5615
|
+
}));
|
|
5616
|
+
return out;
|
|
5617
|
+
}
|
|
5618
|
+
/**
|
|
4665
5619
|
* Runtime-wide constants (Step 7 + Step 11).
|
|
4666
5620
|
*
|
|
4667
5621
|
* After Step 11 these values are fixed at code level — the previously
|
|
@@ -5285,7 +6239,7 @@ var ClientRuntime = class {
|
|
|
5285
6239
|
});
|
|
5286
6240
|
const yaml = stringify({
|
|
5287
6241
|
agentId: message.agentId,
|
|
5288
|
-
runtime:
|
|
6242
|
+
runtime: message.runtimeProvider
|
|
5289
6243
|
});
|
|
5290
6244
|
writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
|
|
5291
6245
|
print.check(true, `auto-added agent "${localName}"`, `${message.agentId} (from server push)`);
|
|
@@ -6598,7 +7552,7 @@ async function onboardCreate(args) {
|
|
|
6598
7552
|
}
|
|
6599
7553
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
6600
7554
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
6601
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
7555
|
+
const { bindFeishuBot } = await import("./feishu-DvjRZMdZ.mjs").then((n) => n.r);
|
|
6602
7556
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
6603
7557
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
6604
7558
|
else {
|
|
@@ -6761,6 +7715,66 @@ function setNestedByDot(obj, dotPath, value) {
|
|
|
6761
7715
|
if (lastKey !== void 0) current[lastKey] = value;
|
|
6762
7716
|
}
|
|
6763
7717
|
//#endregion
|
|
7718
|
+
//#region src/core/runtime-provider-reconcile.ts
|
|
7719
|
+
/**
|
|
7720
|
+
* Pre-flight reconciliation called before the agents loop spawns. Pulls
|
|
7721
|
+
* authoritative `runtime_provider` for every agent the calling user owns and
|
|
7722
|
+
* rewrites any local `agent.yaml` whose `runtime` field disagrees. Best-
|
|
7723
|
+
* effort: a transient hub failure logs and falls back to the local YAML
|
|
7724
|
+
* value (the in-band repair path catches any remaining drift on first bind).
|
|
7725
|
+
*/
|
|
7726
|
+
async function reconcileLocalRuntimeProviders(opts) {
|
|
7727
|
+
const res = await fetch(`${opts.serverUrl}/api/v1/clients/me/agents`, { headers: { Authorization: `Bearer ${opts.accessToken}` } });
|
|
7728
|
+
if (!res.ok) throw new Error(`hub returned ${res.status} on /clients/me/agents`);
|
|
7729
|
+
const items = await res.json();
|
|
7730
|
+
const byAgentId = new Map(items.map((it) => [it.agentId, it]));
|
|
7731
|
+
if (!existsSync(opts.agentsDir)) return;
|
|
7732
|
+
const subdirs = readdirSync(opts.agentsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
7733
|
+
for (const subdir of subdirs) {
|
|
7734
|
+
const yamlPath = join(opts.agentsDir, subdir.name, "agent.yaml");
|
|
7735
|
+
if (!existsSync(yamlPath)) continue;
|
|
7736
|
+
let parsed;
|
|
7737
|
+
try {
|
|
7738
|
+
parsed = parse(readFileSync(yamlPath, "utf-8")) ?? {};
|
|
7739
|
+
} catch (err) {
|
|
7740
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7741
|
+
opts.log?.("warn", `agent ${subdir.name}: cannot parse yaml — ${msg}`);
|
|
7742
|
+
continue;
|
|
7743
|
+
}
|
|
7744
|
+
if (!parsed.agentId) continue;
|
|
7745
|
+
const auth = byAgentId.get(parsed.agentId);
|
|
7746
|
+
if (!auth) continue;
|
|
7747
|
+
if (parsed.runtime === auth.runtimeProvider) continue;
|
|
7748
|
+
const next = {
|
|
7749
|
+
...parsed,
|
|
7750
|
+
runtime: auth.runtimeProvider
|
|
7751
|
+
};
|
|
7752
|
+
try {
|
|
7753
|
+
writeFileSync(yamlPath, stringify(next), { mode: 384 });
|
|
7754
|
+
opts.log?.("info", `agent ${parsed.agentId}: yaml runtime "${parsed.runtime ?? "(unset)"}" → "${auth.runtimeProvider}" (hub authoritative)`);
|
|
7755
|
+
} catch (err) {
|
|
7756
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7757
|
+
opts.log?.("warn", `agent ${parsed.agentId}: failed to rewrite yaml — ${msg}`);
|
|
7758
|
+
}
|
|
7759
|
+
}
|
|
7760
|
+
}
|
|
7761
|
+
/**
|
|
7762
|
+
* Member-scoped capabilities upload. Server stores the snapshot under
|
|
7763
|
+
* `clients.metadata.capabilities`. Best-effort: failure does not block
|
|
7764
|
+
* client startup since capabilities only matter for UI / admin checks.
|
|
7765
|
+
*/
|
|
7766
|
+
async function uploadClientCapabilities(opts) {
|
|
7767
|
+
const res = await fetch(`${opts.serverUrl}/api/v1/clients/${encodeURIComponent(opts.clientId)}/capabilities`, {
|
|
7768
|
+
method: "PATCH",
|
|
7769
|
+
headers: {
|
|
7770
|
+
Authorization: `Bearer ${opts.accessToken}`,
|
|
7771
|
+
"Content-Type": "application/json"
|
|
7772
|
+
},
|
|
7773
|
+
body: JSON.stringify({ capabilities: opts.capabilities })
|
|
7774
|
+
});
|
|
7775
|
+
if (!res.ok) throw new Error(`hub returned ${res.status} on PATCH /clients/${opts.clientId}/capabilities`);
|
|
7776
|
+
}
|
|
7777
|
+
//#endregion
|
|
6764
7778
|
//#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
|
|
6765
7779
|
const FeedbackType = z.enum(["bug", "feature"]);
|
|
6766
7780
|
const BrowserContext = z.object({
|
|
@@ -7518,7 +8532,7 @@ function createFeedbackHandler(config) {
|
|
|
7518
8532
|
return { handle };
|
|
7519
8533
|
}
|
|
7520
8534
|
//#endregion
|
|
7521
|
-
//#region ../server/dist/app-
|
|
8535
|
+
//#region ../server/dist/app-fbgPnPWI.mjs
|
|
7522
8536
|
var __defProp = Object.defineProperty;
|
|
7523
8537
|
var __exportAll = (all, no_symbols) => {
|
|
7524
8538
|
let target = {};
|
|
@@ -7572,6 +8586,7 @@ const agents = pgTable("agents", {
|
|
|
7572
8586
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
7573
8587
|
managerId: text("manager_id").notNull(),
|
|
7574
8588
|
clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
|
|
8589
|
+
runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
|
|
7575
8590
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7576
8591
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7577
8592
|
}, (table) => [
|
|
@@ -8241,7 +9256,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
8241
9256
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
8242
9257
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
8243
9258
|
}
|
|
8244
|
-
const log$
|
|
9259
|
+
const log$5 = createLogger$1("AdminAdapters");
|
|
8245
9260
|
function parseId(raw) {
|
|
8246
9261
|
const id = Number(raw);
|
|
8247
9262
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -8261,7 +9276,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8261
9276
|
const scope = memberScope(request);
|
|
8262
9277
|
await assertCanManage(app.db, scope, body.agentId);
|
|
8263
9278
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
8264
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9279
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after create"));
|
|
8265
9280
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8266
9281
|
return reply.status(201).send({
|
|
8267
9282
|
...config,
|
|
@@ -8285,7 +9300,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8285
9300
|
const existing = await getAdapterConfig(app.db, id);
|
|
8286
9301
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
8287
9302
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
8288
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9303
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
|
|
8289
9304
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8290
9305
|
return {
|
|
8291
9306
|
...config,
|
|
@@ -8299,7 +9314,7 @@ async function adminAdapterRoutes(app) {
|
|
|
8299
9314
|
const existing = await getAdapterConfig(app.db, id);
|
|
8300
9315
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
8301
9316
|
await deleteAdapterConfig(app.db, id);
|
|
8302
|
-
app.adapterManager.reload().catch((err) => log$
|
|
9317
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
|
|
8303
9318
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
8304
9319
|
return reply.status(204).send();
|
|
8305
9320
|
});
|
|
@@ -8400,6 +9415,38 @@ const members = pgTable("members", {
|
|
|
8400
9415
|
* real account.
|
|
8401
9416
|
*/
|
|
8402
9417
|
const RESERVED_AGENT_NAME_PREFIX = "__";
|
|
9418
|
+
/**
|
|
9419
|
+
* True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
|
|
9420
|
+
* client has reported at least one runtime probe result. Used to distinguish
|
|
9421
|
+
* "we don't know what's installed yet" (empty / never reported) from
|
|
9422
|
+
* "client explicitly reports this provider is missing".
|
|
9423
|
+
*/
|
|
9424
|
+
function clientCapabilitiesReported(metadata) {
|
|
9425
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
9426
|
+
const caps = metadata.capabilities;
|
|
9427
|
+
if (!caps || typeof caps !== "object") return false;
|
|
9428
|
+
return Object.keys(caps).length > 0;
|
|
9429
|
+
}
|
|
9430
|
+
/**
|
|
9431
|
+
* Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
|
|
9432
|
+
* runtime provider entry. Capabilities live under the `metadata.capabilities`
|
|
9433
|
+
* subkey (Option C); the column is unstructured at the DB layer, so we
|
|
9434
|
+
* defensively narrow before key access.
|
|
9435
|
+
*
|
|
9436
|
+
* "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
|
|
9437
|
+
* `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
|
|
9438
|
+
* not usable, so we explicitly reject those rather than treating mere key
|
|
9439
|
+
* presence as support. Auth state is left to the user to fix at runtime
|
|
9440
|
+
* (the re-bind dialog surfaces an `unauthenticated` hint).
|
|
9441
|
+
*/
|
|
9442
|
+
function clientSupportsRuntimeProvider(metadata, provider) {
|
|
9443
|
+
if (!metadata || typeof metadata !== "object") return false;
|
|
9444
|
+
const caps = metadata.capabilities;
|
|
9445
|
+
if (!caps || typeof caps !== "object") return false;
|
|
9446
|
+
const entry = caps[provider];
|
|
9447
|
+
if (!entry || typeof entry !== "object") return false;
|
|
9448
|
+
return entry.available === true;
|
|
9449
|
+
}
|
|
8403
9450
|
/** Default visibility per agent type. */
|
|
8404
9451
|
function defaultVisibility(type) {
|
|
8405
9452
|
switch (type) {
|
|
@@ -8420,6 +9467,32 @@ function defaultVisibility(type) {
|
|
|
8420
9467
|
* - When a non-human agent IS created with a `clientId`, the pinned client
|
|
8421
9468
|
* must already be owned by the manager's user (Rule R-RUN).
|
|
8422
9469
|
*/
|
|
9470
|
+
/**
|
|
9471
|
+
* Check that a client's reported capabilities show the given runtime provider
|
|
9472
|
+
* as **available** (SDK installed, regardless of auth state).
|
|
9473
|
+
*
|
|
9474
|
+
* Tri-state semantics by `clients.metadata.capabilities` shape:
|
|
9475
|
+
* - empty / absent — client hasn't probed yet (newly registered or pre-P2
|
|
9476
|
+
* install). Treat as "unknown" and allow; the in-band repair path
|
|
9477
|
+
* (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
|
|
9478
|
+
* - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
|
|
9479
|
+
* true`) — allow.
|
|
9480
|
+
* - reported, entry missing OR `state: missing | error` — block unless
|
|
9481
|
+
* `force` is set. We deliberately do NOT treat mere key presence as
|
|
9482
|
+
* support: probeCapabilities() always emits an entry per built-in
|
|
9483
|
+
* provider, including `{ state: "missing" }` for absent SDKs.
|
|
9484
|
+
*
|
|
9485
|
+
* Skipped entirely for human agents (no clientId) and when `force` is set
|
|
9486
|
+
* (e.g. operator overrides for an offline client).
|
|
9487
|
+
*/
|
|
9488
|
+
async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
|
|
9489
|
+
if (clientId === null) return;
|
|
9490
|
+
if (options.force) return;
|
|
9491
|
+
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
9492
|
+
if (!client) return;
|
|
9493
|
+
if (!clientCapabilitiesReported(client.metadata)) return;
|
|
9494
|
+
if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
|
|
9495
|
+
}
|
|
8423
9496
|
async function resolveAgentClient(db, data) {
|
|
8424
9497
|
if (data.type === "human") {
|
|
8425
9498
|
if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
|
|
@@ -8452,9 +9525,10 @@ async function resolveFallbackManagerId(db, orgId) {
|
|
|
8452
9525
|
if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" — no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
|
|
8453
9526
|
return row.id;
|
|
8454
9527
|
}
|
|
8455
|
-
async function createAgent(db, data) {
|
|
9528
|
+
async function createAgent(db, data, options = {}) {
|
|
8456
9529
|
const uuid = uuidv7();
|
|
8457
9530
|
const name = data.name ?? null;
|
|
9531
|
+
const runtimeProvider = data.runtimeProvider ?? "claude-code";
|
|
8458
9532
|
if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
|
|
8459
9533
|
if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
|
|
8460
9534
|
const inboxId = `inbox_${uuid}`;
|
|
@@ -8480,6 +9554,7 @@ async function createAgent(db, data) {
|
|
|
8480
9554
|
managerId,
|
|
8481
9555
|
type: data.type
|
|
8482
9556
|
});
|
|
9557
|
+
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
8483
9558
|
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
8484
9559
|
if (org && org.maxAgents > 0) {
|
|
8485
9560
|
if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
|
|
@@ -8498,13 +9573,14 @@ async function createAgent(db, data) {
|
|
|
8498
9573
|
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
8499
9574
|
metadata: data.metadata ?? {},
|
|
8500
9575
|
managerId,
|
|
8501
|
-
clientId
|
|
9576
|
+
clientId,
|
|
9577
|
+
runtimeProvider
|
|
8502
9578
|
}).returning();
|
|
8503
9579
|
if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
8504
9580
|
await db.insert(agentConfigs).values({
|
|
8505
9581
|
agentId: agent.uuid,
|
|
8506
9582
|
version: 1,
|
|
8507
|
-
payload:
|
|
9583
|
+
payload: defaultRuntimeConfigPayload(runtimeProvider),
|
|
8508
9584
|
updatedBy: "system"
|
|
8509
9585
|
}).onConflictDoNothing();
|
|
8510
9586
|
return agent;
|
|
@@ -8558,6 +9634,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
|
8558
9634
|
metadata: agents.metadata,
|
|
8559
9635
|
managerId: agents.managerId,
|
|
8560
9636
|
clientId: agents.clientId,
|
|
9637
|
+
runtimeProvider: agents.runtimeProvider,
|
|
8561
9638
|
createdAt: agents.createdAt,
|
|
8562
9639
|
updatedAt: agents.updatedAt,
|
|
8563
9640
|
presenceStatus: agentPresence.status,
|
|
@@ -8595,6 +9672,7 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
|
8595
9672
|
metadata: agents.metadata,
|
|
8596
9673
|
managerId: agents.managerId,
|
|
8597
9674
|
clientId: agents.clientId,
|
|
9675
|
+
runtimeProvider: agents.runtimeProvider,
|
|
8598
9676
|
createdAt: agents.createdAt,
|
|
8599
9677
|
updatedAt: agents.updatedAt,
|
|
8600
9678
|
presenceStatus: agentPresence.status,
|
|
@@ -8614,7 +9692,7 @@ async function updateAgent(db, uuid, data) {
|
|
|
8614
9692
|
const agent = await getAgent(db, uuid);
|
|
8615
9693
|
if (data.clientId !== void 0) {
|
|
8616
9694
|
if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
|
|
8617
|
-
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable
|
|
9695
|
+
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /admin/agents/:agentId/rebind), which runs owner / org / capability checks atomically.");
|
|
8618
9696
|
}
|
|
8619
9697
|
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
8620
9698
|
if (data.type !== void 0) updates.type = data.type;
|
|
@@ -8645,6 +9723,39 @@ async function updateAgent(db, uuid, data) {
|
|
|
8645
9723
|
return updated;
|
|
8646
9724
|
}
|
|
8647
9725
|
/**
|
|
9726
|
+
* Atomically re-bind an agent to a new client and/or runtime provider.
|
|
9727
|
+
*
|
|
9728
|
+
* Validations: agent must exist and not be human; new client must belong to
|
|
9729
|
+
* the same owner (manager.userId) and same organization; client must report
|
|
9730
|
+
* the requested runtime provider in its capabilities (skipped under `force`).
|
|
9731
|
+
*
|
|
9732
|
+
* Intended caller: PATCH /admin/agents/:agentId/rebind. The Web "Re-bind"
|
|
9733
|
+
* dialog routes both same-client runtime-only switches and cross-client
|
|
9734
|
+
* moves through this single entry.
|
|
9735
|
+
*
|
|
9736
|
+
* NOTE: active sessions on the previous client are not auto-suspended in P1.
|
|
9737
|
+
* P3 will wire in cross-service coordination (inbox + presence + session)
|
|
9738
|
+
* so the destination client can resume cleanly.
|
|
9739
|
+
*/
|
|
9740
|
+
async function rebindAgent(db, uuid, data) {
|
|
9741
|
+
const agent = await getAgent(db, uuid);
|
|
9742
|
+
if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
|
|
9743
|
+
const newClientId = await resolveAgentClient(db, {
|
|
9744
|
+
clientId: data.clientId,
|
|
9745
|
+
managerId: agent.managerId,
|
|
9746
|
+
type: agent.type
|
|
9747
|
+
});
|
|
9748
|
+
if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
|
|
9749
|
+
await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
|
|
9750
|
+
const [updated] = await db.update(agents).set({
|
|
9751
|
+
clientId: newClientId,
|
|
9752
|
+
runtimeProvider: data.runtimeProvider,
|
|
9753
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
9754
|
+
}).where(eq(agents.uuid, uuid)).returning();
|
|
9755
|
+
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
9756
|
+
return updated;
|
|
9757
|
+
}
|
|
9758
|
+
/**
|
|
8648
9759
|
* Reactivate a suspended agent.
|
|
8649
9760
|
*/
|
|
8650
9761
|
async function reactivateAgent(db, uuid) {
|
|
@@ -9224,10 +10335,45 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
9224
10335
|
uuid: agents.uuid,
|
|
9225
10336
|
name: agents.name,
|
|
9226
10337
|
displayName: agents.displayName,
|
|
9227
|
-
type: agents.type
|
|
10338
|
+
type: agents.type,
|
|
10339
|
+
runtimeProvider: agents.runtimeProvider
|
|
9228
10340
|
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
9229
10341
|
}
|
|
9230
10342
|
/**
|
|
10343
|
+
* Member-scoped: every active agent pinned to a client owned by this user
|
|
10344
|
+
* within the given organization. Used by client startup to reconcile its
|
|
10345
|
+
* local YAML against the authoritative `agents.runtime_provider`.
|
|
10346
|
+
*/
|
|
10347
|
+
async function listMyPinnedAgents(db, scope) {
|
|
10348
|
+
return (await db.select({
|
|
10349
|
+
agentId: agents.uuid,
|
|
10350
|
+
clientId: agents.clientId,
|
|
10351
|
+
runtimeProvider: agents.runtimeProvider
|
|
10352
|
+
}).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
|
|
10353
|
+
agentId: r.agentId,
|
|
10354
|
+
clientId: r.clientId,
|
|
10355
|
+
runtimeProvider: r.runtimeProvider
|
|
10356
|
+
}));
|
|
10357
|
+
}
|
|
10358
|
+
/**
|
|
10359
|
+
* Replace this client's capabilities snapshot. Capabilities live under
|
|
10360
|
+
* `clients.metadata.capabilities` (Option C — no dedicated column); other
|
|
10361
|
+
* `metadata` subkeys are preserved on merge.
|
|
10362
|
+
*
|
|
10363
|
+
* Caller is expected to have already passed `assertClientOwner`.
|
|
10364
|
+
*/
|
|
10365
|
+
async function updateClientCapabilities(db, clientId, capabilities) {
|
|
10366
|
+
const parsed = clientCapabilitiesSchema$1.safeParse(capabilities);
|
|
10367
|
+
if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
|
|
10368
|
+
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
10369
|
+
if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10370
|
+
const merged = {
|
|
10371
|
+
...client.metadata ?? {},
|
|
10372
|
+
capabilities: parsed.data
|
|
10373
|
+
};
|
|
10374
|
+
await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
|
|
10375
|
+
}
|
|
10376
|
+
/**
|
|
9231
10377
|
* Scope-aware client listing.
|
|
9232
10378
|
*
|
|
9233
10379
|
* - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
|
|
@@ -9422,24 +10568,144 @@ function forceDisconnectClient(clientId) {
|
|
|
9422
10568
|
clientConnections.delete(clientId);
|
|
9423
10569
|
return agentIds;
|
|
9424
10570
|
}
|
|
9425
|
-
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
9426
|
-
const inboxEntries = pgTable("inbox_entries", {
|
|
9427
|
-
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
9428
|
-
inboxId: text("inbox_id").notNull(),
|
|
9429
|
-
messageId: text("message_id").notNull().references(() => messages.id),
|
|
9430
|
-
chatId: text("chat_id"),
|
|
9431
|
-
status: text("status").notNull().default("pending"),
|
|
9432
|
-
notify: boolean("notify").notNull().default(true),
|
|
9433
|
-
retryCount: integer("retry_count").notNull().default(0),
|
|
9434
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9435
|
-
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
9436
|
-
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
9437
|
-
}, (table) => [
|
|
9438
|
-
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
9439
|
-
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
9440
|
-
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
9441
|
-
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
9442
|
-
]);
|
|
10571
|
+
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
10572
|
+
const inboxEntries = pgTable("inbox_entries", {
|
|
10573
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
10574
|
+
inboxId: text("inbox_id").notNull(),
|
|
10575
|
+
messageId: text("message_id").notNull().references(() => messages.id),
|
|
10576
|
+
chatId: text("chat_id"),
|
|
10577
|
+
status: text("status").notNull().default("pending"),
|
|
10578
|
+
notify: boolean("notify").notNull().default(true),
|
|
10579
|
+
retryCount: integer("retry_count").notNull().default(0),
|
|
10580
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
10581
|
+
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
10582
|
+
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
10583
|
+
}, (table) => [
|
|
10584
|
+
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
10585
|
+
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
10586
|
+
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
10587
|
+
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
10588
|
+
]);
|
|
10589
|
+
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
10590
|
+
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
10591
|
+
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
10592
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
10593
|
+
state: text("state").notNull(),
|
|
10594
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10595
|
+
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
10596
|
+
/**
|
|
10597
|
+
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
10598
|
+
*
|
|
10599
|
+
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
10600
|
+
* state" cache, not a session history log. A new runtime session starting on
|
|
10601
|
+
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
10602
|
+
* an `evicted` row left by a previous terminate. The previous "revival
|
|
10603
|
+
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
10604
|
+
* what `evicted` actually means) and "this chat is permanently archived for
|
|
10605
|
+
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
10606
|
+
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
10607
|
+
*
|
|
10608
|
+
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
10609
|
+
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
10610
|
+
* (sendMessage on first message) may target an agent whose client has never
|
|
10611
|
+
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
10612
|
+
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
10613
|
+
*/
|
|
10614
|
+
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
10615
|
+
const now = /* @__PURE__ */ new Date();
|
|
10616
|
+
let wrote = false;
|
|
10617
|
+
await db.transaction(async (tx) => {
|
|
10618
|
+
await tx.insert(agentChatSessions).values({
|
|
10619
|
+
agentId,
|
|
10620
|
+
chatId,
|
|
10621
|
+
state,
|
|
10622
|
+
updatedAt: now
|
|
10623
|
+
}).onConflictDoUpdate({
|
|
10624
|
+
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
10625
|
+
set: {
|
|
10626
|
+
state,
|
|
10627
|
+
updatedAt: now
|
|
10628
|
+
},
|
|
10629
|
+
setWhere: ne(agentChatSessions.state, state)
|
|
10630
|
+
});
|
|
10631
|
+
const [counts] = await tx.select({
|
|
10632
|
+
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
10633
|
+
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
10634
|
+
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
10635
|
+
const activeSessions = counts?.active ?? 0;
|
|
10636
|
+
const totalSessions = counts?.total ?? 0;
|
|
10637
|
+
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
10638
|
+
activeSessions,
|
|
10639
|
+
totalSessions,
|
|
10640
|
+
lastSeenAt: now
|
|
10641
|
+
} : {
|
|
10642
|
+
activeSessions,
|
|
10643
|
+
totalSessions
|
|
10644
|
+
};
|
|
10645
|
+
await tx.insert(agentPresence).values({
|
|
10646
|
+
agentId,
|
|
10647
|
+
activeSessions,
|
|
10648
|
+
totalSessions
|
|
10649
|
+
}).onConflictDoUpdate({
|
|
10650
|
+
target: [agentPresence.agentId],
|
|
10651
|
+
set: presenceSet
|
|
10652
|
+
});
|
|
10653
|
+
wrote = true;
|
|
10654
|
+
});
|
|
10655
|
+
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
10656
|
+
}
|
|
10657
|
+
async function resetActivity(db, agentId) {
|
|
10658
|
+
const now = /* @__PURE__ */ new Date();
|
|
10659
|
+
await db.update(agentPresence).set({
|
|
10660
|
+
runtimeState: "idle",
|
|
10661
|
+
runtimeUpdatedAt: now
|
|
10662
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
10663
|
+
}
|
|
10664
|
+
async function getActivityOverview(db) {
|
|
10665
|
+
const [agentCounts] = await db.select({
|
|
10666
|
+
total: sql`count(*)::int`,
|
|
10667
|
+
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
10668
|
+
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
10669
|
+
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
10670
|
+
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
10671
|
+
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
10672
|
+
}).from(agentPresence);
|
|
10673
|
+
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
10674
|
+
return {
|
|
10675
|
+
total: agentCounts?.total ?? 0,
|
|
10676
|
+
running: agentCounts?.running ?? 0,
|
|
10677
|
+
byState: {
|
|
10678
|
+
idle: agentCounts?.idle ?? 0,
|
|
10679
|
+
working: agentCounts?.working ?? 0,
|
|
10680
|
+
blocked: agentCounts?.blocked ?? 0,
|
|
10681
|
+
error: agentCounts?.error ?? 0
|
|
10682
|
+
},
|
|
10683
|
+
clients: clientCounts?.count ?? 0
|
|
10684
|
+
};
|
|
10685
|
+
}
|
|
10686
|
+
/**
|
|
10687
|
+
* List agents with active runtime state.
|
|
10688
|
+
* When scope is provided, filters to agents visible to the member.
|
|
10689
|
+
*/
|
|
10690
|
+
async function listAgentsWithRuntime(db, scope) {
|
|
10691
|
+
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
10692
|
+
return db.select({
|
|
10693
|
+
agentId: agentPresence.agentId,
|
|
10694
|
+
status: agentPresence.status,
|
|
10695
|
+
instanceId: agentPresence.instanceId,
|
|
10696
|
+
connectedAt: agentPresence.connectedAt,
|
|
10697
|
+
lastSeenAt: agentPresence.lastSeenAt,
|
|
10698
|
+
clientId: agentPresence.clientId,
|
|
10699
|
+
runtimeType: agentPresence.runtimeType,
|
|
10700
|
+
runtimeVersion: agentPresence.runtimeVersion,
|
|
10701
|
+
runtimeState: agentPresence.runtimeState,
|
|
10702
|
+
activeSessions: agentPresence.activeSessions,
|
|
10703
|
+
totalSessions: agentPresence.totalSessions,
|
|
10704
|
+
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
10705
|
+
type: agents.type
|
|
10706
|
+
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
10707
|
+
}
|
|
10708
|
+
const log$4 = createLogger$1("message");
|
|
9443
10709
|
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
9444
10710
|
return withSpan("inbox.enqueue", messageAttrs({
|
|
9445
10711
|
chatId,
|
|
@@ -9448,17 +10714,24 @@ async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
|
9448
10714
|
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
9449
10715
|
}
|
|
9450
10716
|
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
9451
|
-
|
|
9452
|
-
const [participants, [chatRow]] = await Promise.all([
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
|
|
10717
|
+
const txResult = await db.transaction(async (tx) => {
|
|
10718
|
+
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
10719
|
+
tx.select({
|
|
10720
|
+
agentId: chatParticipants.agentId,
|
|
10721
|
+
inboxId: agents.inboxId,
|
|
10722
|
+
mode: chatParticipants.mode,
|
|
10723
|
+
name: agents.name
|
|
10724
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
|
|
10725
|
+
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
10726
|
+
tx.select({
|
|
10727
|
+
inboxId: agents.inboxId,
|
|
10728
|
+
organizationId: agents.organizationId
|
|
10729
|
+
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
10730
|
+
]);
|
|
9458
10731
|
const chatType = chatRow?.type ?? null;
|
|
10732
|
+
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
9459
10733
|
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
9460
|
-
|
|
9461
|
-
if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
10734
|
+
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
9462
10735
|
}
|
|
9463
10736
|
const incomingMeta = data.metadata ?? {};
|
|
9464
10737
|
const explicitMentionsRaw = incomingMeta.mentions;
|
|
@@ -9503,14 +10776,20 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
9503
10776
|
source: data.source ?? null
|
|
9504
10777
|
}).returning();
|
|
9505
10778
|
const mentionSet = new Set(mergedMentions);
|
|
9506
|
-
const
|
|
10779
|
+
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
10780
|
+
agentId: p.agentId,
|
|
9507
10781
|
inboxId: p.inboxId,
|
|
9508
|
-
messageId,
|
|
9509
|
-
chatId,
|
|
9510
10782
|
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
9511
10783
|
}));
|
|
9512
|
-
if (
|
|
9513
|
-
|
|
10784
|
+
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
10785
|
+
inboxId: f.inboxId,
|
|
10786
|
+
messageId,
|
|
10787
|
+
chatId,
|
|
10788
|
+
notify: f.notify
|
|
10789
|
+
})));
|
|
10790
|
+
const notified = fanout.filter((f) => f.notify);
|
|
10791
|
+
const recipients = notified.map((f) => f.inboxId);
|
|
10792
|
+
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
9514
10793
|
if (data.inReplyTo) {
|
|
9515
10794
|
const [original] = await tx.select({
|
|
9516
10795
|
replyToInbox: messages.replyToInbox,
|
|
@@ -9529,9 +10808,24 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
9529
10808
|
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
9530
10809
|
return {
|
|
9531
10810
|
message: msg,
|
|
9532
|
-
recipients
|
|
10811
|
+
recipients,
|
|
10812
|
+
recipientAgentIds,
|
|
10813
|
+
organizationId: senderRow.organizationId
|
|
9533
10814
|
};
|
|
9534
10815
|
});
|
|
10816
|
+
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
10817
|
+
for (let i = 0; i < settled.length; i++) {
|
|
10818
|
+
const r = settled[i];
|
|
10819
|
+
if (r?.status === "rejected") log$4.error({
|
|
10820
|
+
err: r.reason,
|
|
10821
|
+
chatId,
|
|
10822
|
+
agentId: txResult.recipientAgentIds[i]
|
|
10823
|
+
}, "predictive session activation failed");
|
|
10824
|
+
}
|
|
10825
|
+
return {
|
|
10826
|
+
message: txResult.message,
|
|
10827
|
+
recipients: txResult.recipients
|
|
10828
|
+
};
|
|
9535
10829
|
}
|
|
9536
10830
|
async function sendToAgent(db, senderUuid, targetName, data) {
|
|
9537
10831
|
const [sender] = await db.select({
|
|
@@ -9605,27 +10899,31 @@ function createNotifier(listenClient) {
|
|
|
9605
10899
|
const messageId = payload.slice(sepIdx + 1);
|
|
9606
10900
|
const sockets = subscriptions.get(inboxId);
|
|
9607
10901
|
if (!sockets) return;
|
|
9608
|
-
const
|
|
10902
|
+
const doorbellFrame = JSON.stringify({
|
|
9609
10903
|
type: "new_message",
|
|
9610
10904
|
inboxId,
|
|
9611
10905
|
messageId
|
|
9612
10906
|
});
|
|
9613
|
-
for (const ws of sockets)
|
|
10907
|
+
for (const [ws, pushHandler] of sockets) {
|
|
10908
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
10909
|
+
if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
|
|
10910
|
+
else ws.send(doorbellFrame);
|
|
10911
|
+
}
|
|
9614
10912
|
}
|
|
9615
10913
|
return {
|
|
9616
|
-
subscribe(inboxId, ws) {
|
|
9617
|
-
let
|
|
9618
|
-
if (!
|
|
9619
|
-
|
|
9620
|
-
subscriptions.set(inboxId,
|
|
10914
|
+
subscribe(inboxId, ws, pushHandler) {
|
|
10915
|
+
let map = subscriptions.get(inboxId);
|
|
10916
|
+
if (!map) {
|
|
10917
|
+
map = /* @__PURE__ */ new Map();
|
|
10918
|
+
subscriptions.set(inboxId, map);
|
|
9621
10919
|
}
|
|
9622
|
-
set
|
|
10920
|
+
map.set(ws, pushHandler ?? null);
|
|
9623
10921
|
},
|
|
9624
10922
|
unsubscribe(inboxId, ws) {
|
|
9625
|
-
const
|
|
9626
|
-
if (
|
|
9627
|
-
|
|
9628
|
-
if (
|
|
10923
|
+
const map = subscriptions.get(inboxId);
|
|
10924
|
+
if (map) {
|
|
10925
|
+
map.delete(ws);
|
|
10926
|
+
if (map.size === 0) subscriptions.delete(inboxId);
|
|
9629
10927
|
}
|
|
9630
10928
|
},
|
|
9631
10929
|
async notify(inboxId, messageId) {
|
|
@@ -9649,11 +10947,11 @@ function createNotifier(listenClient) {
|
|
|
9649
10947
|
} catch {}
|
|
9650
10948
|
},
|
|
9651
10949
|
async pushFrameToInbox(inboxId, frame) {
|
|
9652
|
-
const
|
|
9653
|
-
if (!
|
|
10950
|
+
const map = subscriptions.get(inboxId);
|
|
10951
|
+
if (!map) return 0;
|
|
9654
10952
|
let queued = 0;
|
|
9655
10953
|
const pending = [];
|
|
9656
|
-
for (const ws of
|
|
10954
|
+
for (const ws of map.keys()) {
|
|
9657
10955
|
if (ws.readyState !== ws.OPEN) continue;
|
|
9658
10956
|
pending.push(new Promise((resolve) => {
|
|
9659
10957
|
ws.send(frame, (err) => {
|
|
@@ -9758,7 +11056,8 @@ async function adminAgentRoutes(app) {
|
|
|
9758
11056
|
agentId: agent.uuid,
|
|
9759
11057
|
name: agent.name,
|
|
9760
11058
|
displayName: agent.displayName,
|
|
9761
|
-
agentType: agent.type
|
|
11059
|
+
agentType: agent.type,
|
|
11060
|
+
runtimeProvider: agent.runtimeProvider
|
|
9762
11061
|
});
|
|
9763
11062
|
if (!parsed.success) {
|
|
9764
11063
|
app.log.warn({
|
|
@@ -9861,6 +11160,23 @@ async function adminAgentRoutes(app) {
|
|
|
9861
11160
|
updatedAt: agent.updatedAt.toISOString()
|
|
9862
11161
|
};
|
|
9863
11162
|
});
|
|
11163
|
+
/**
|
|
11164
|
+
* Rebind an agent to a new client and/or runtime provider. Re-runs owner /
|
|
11165
|
+
* org / capability checks atomically. Capability mismatch can be overridden
|
|
11166
|
+
* with `force: true` (e.g. client offline, capabilities stale).
|
|
11167
|
+
*/
|
|
11168
|
+
app.patch("/:uuid/rebind", async (request) => {
|
|
11169
|
+
const scope = memberScope(request);
|
|
11170
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
11171
|
+
const body = rebindAgentSchema.parse(request.body);
|
|
11172
|
+
const agent = await rebindAgent(app.db, request.params.uuid, body);
|
|
11173
|
+
notifyClientAgentPinned(agent);
|
|
11174
|
+
return {
|
|
11175
|
+
...agent,
|
|
11176
|
+
createdAt: agent.createdAt.toISOString(),
|
|
11177
|
+
updatedAt: agent.updatedAt.toISOString()
|
|
11178
|
+
};
|
|
11179
|
+
});
|
|
9864
11180
|
app.get("/:uuid", async (request) => {
|
|
9865
11181
|
const scope = memberScope(request);
|
|
9866
11182
|
await assertAgentVisible(app.db, scope, request.params.uuid);
|
|
@@ -10315,107 +11631,6 @@ async function adminChatRoutes(app) {
|
|
|
10315
11631
|
});
|
|
10316
11632
|
});
|
|
10317
11633
|
}
|
|
10318
|
-
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
10319
|
-
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
10320
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
10321
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
10322
|
-
state: text("state").notNull(),
|
|
10323
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10324
|
-
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
10325
|
-
/**
|
|
10326
|
-
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
10327
|
-
*
|
|
10328
|
-
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
10329
|
-
* state" cache, not a session history log. A new runtime session starting on
|
|
10330
|
-
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
10331
|
-
* an `evicted` row left by a previous terminate. The previous "revival
|
|
10332
|
-
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
10333
|
-
* what `evicted` actually means) and "this chat is permanently archived for
|
|
10334
|
-
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
10335
|
-
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
10336
|
-
*/
|
|
10337
|
-
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
|
|
10338
|
-
const now = /* @__PURE__ */ new Date();
|
|
10339
|
-
let wrote = false;
|
|
10340
|
-
await db.transaction(async (tx) => {
|
|
10341
|
-
await tx.insert(agentChatSessions).values({
|
|
10342
|
-
agentId,
|
|
10343
|
-
chatId,
|
|
10344
|
-
state,
|
|
10345
|
-
updatedAt: now
|
|
10346
|
-
}).onConflictDoUpdate({
|
|
10347
|
-
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
10348
|
-
set: {
|
|
10349
|
-
state,
|
|
10350
|
-
updatedAt: now
|
|
10351
|
-
}
|
|
10352
|
-
});
|
|
10353
|
-
const [counts] = await tx.select({
|
|
10354
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
10355
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
10356
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
10357
|
-
const activeSessions = counts?.active ?? 0;
|
|
10358
|
-
const totalSessions = counts?.total ?? 0;
|
|
10359
|
-
await tx.update(agentPresence).set({
|
|
10360
|
-
activeSessions,
|
|
10361
|
-
totalSessions,
|
|
10362
|
-
lastSeenAt: now
|
|
10363
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
10364
|
-
wrote = true;
|
|
10365
|
-
});
|
|
10366
|
-
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
10367
|
-
}
|
|
10368
|
-
async function resetActivity(db, agentId) {
|
|
10369
|
-
const now = /* @__PURE__ */ new Date();
|
|
10370
|
-
await db.update(agentPresence).set({
|
|
10371
|
-
runtimeState: "idle",
|
|
10372
|
-
runtimeUpdatedAt: now
|
|
10373
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
10374
|
-
}
|
|
10375
|
-
async function getActivityOverview(db) {
|
|
10376
|
-
const [agentCounts] = await db.select({
|
|
10377
|
-
total: sql`count(*)::int`,
|
|
10378
|
-
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
10379
|
-
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
10380
|
-
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
10381
|
-
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
10382
|
-
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
10383
|
-
}).from(agentPresence);
|
|
10384
|
-
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
10385
|
-
return {
|
|
10386
|
-
total: agentCounts?.total ?? 0,
|
|
10387
|
-
running: agentCounts?.running ?? 0,
|
|
10388
|
-
byState: {
|
|
10389
|
-
idle: agentCounts?.idle ?? 0,
|
|
10390
|
-
working: agentCounts?.working ?? 0,
|
|
10391
|
-
blocked: agentCounts?.blocked ?? 0,
|
|
10392
|
-
error: agentCounts?.error ?? 0
|
|
10393
|
-
},
|
|
10394
|
-
clients: clientCounts?.count ?? 0
|
|
10395
|
-
};
|
|
10396
|
-
}
|
|
10397
|
-
/**
|
|
10398
|
-
* List agents with active runtime state.
|
|
10399
|
-
* When scope is provided, filters to agents visible to the member.
|
|
10400
|
-
*/
|
|
10401
|
-
async function listAgentsWithRuntime(db, scope) {
|
|
10402
|
-
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
10403
|
-
return db.select({
|
|
10404
|
-
agentId: agentPresence.agentId,
|
|
10405
|
-
status: agentPresence.status,
|
|
10406
|
-
instanceId: agentPresence.instanceId,
|
|
10407
|
-
connectedAt: agentPresence.connectedAt,
|
|
10408
|
-
lastSeenAt: agentPresence.lastSeenAt,
|
|
10409
|
-
clientId: agentPresence.clientId,
|
|
10410
|
-
runtimeType: agentPresence.runtimeType,
|
|
10411
|
-
runtimeVersion: agentPresence.runtimeVersion,
|
|
10412
|
-
runtimeState: agentPresence.runtimeState,
|
|
10413
|
-
activeSessions: agentPresence.activeSessions,
|
|
10414
|
-
totalSessions: agentPresence.totalSessions,
|
|
10415
|
-
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
10416
|
-
type: agents.type
|
|
10417
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
10418
|
-
}
|
|
10419
11634
|
/** Serialize a Date to ISO string, or null. */
|
|
10420
11635
|
function serializeDate(d) {
|
|
10421
11636
|
return d ? d.toISOString() : null;
|
|
@@ -10439,11 +11654,27 @@ async function adminClientRoutes(app) {
|
|
|
10439
11654
|
lastSeenAt: c.lastSeenAt.toISOString()
|
|
10440
11655
|
}));
|
|
10441
11656
|
});
|
|
11657
|
+
app.get("/me/agents", async (request) => {
|
|
11658
|
+
const scope = memberScope(request);
|
|
11659
|
+
return await listMyPinnedAgents(app.db, {
|
|
11660
|
+
userId: scope.userId,
|
|
11661
|
+
organizationId: scope.organizationId
|
|
11662
|
+
});
|
|
11663
|
+
});
|
|
11664
|
+
app.patch("/:clientId/capabilities", async (request, reply) => {
|
|
11665
|
+
const scope = memberScope(request);
|
|
11666
|
+
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
11667
|
+
const body = updateClientCapabilitiesSchema.parse(request.body);
|
|
11668
|
+
await updateClientCapabilities(app.db, request.params.clientId, body.capabilities);
|
|
11669
|
+
return reply.status(204).send();
|
|
11670
|
+
});
|
|
10442
11671
|
app.get("/:clientId", async (request) => {
|
|
10443
11672
|
const scope = memberScope(request);
|
|
10444
11673
|
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
10445
11674
|
const client = await getClient(app.db, request.params.clientId);
|
|
10446
11675
|
if (!client) throw new Error("unreachable: client missing after owner check");
|
|
11676
|
+
const metadata = client.metadata ?? {};
|
|
11677
|
+
const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
|
|
10447
11678
|
return {
|
|
10448
11679
|
id: client.id,
|
|
10449
11680
|
userId: client.userId,
|
|
@@ -10452,7 +11683,8 @@ async function adminClientRoutes(app) {
|
|
|
10452
11683
|
hostname: client.hostname,
|
|
10453
11684
|
os: client.os,
|
|
10454
11685
|
connectedAt: serializeDate(client.connectedAt),
|
|
10455
|
-
lastSeenAt: client.lastSeenAt.toISOString()
|
|
11686
|
+
lastSeenAt: client.lastSeenAt.toISOString(),
|
|
11687
|
+
capabilities
|
|
10456
11688
|
};
|
|
10457
11689
|
});
|
|
10458
11690
|
app.post("/:clientId/disconnect", async (request) => {
|
|
@@ -12159,8 +13391,8 @@ async function pollInbox(db, inboxId, limit) {
|
|
|
12159
13391
|
}, () => pollInboxInner(db, inboxId, limit));
|
|
12160
13392
|
}
|
|
12161
13393
|
async function pollInboxInner(db, inboxId, limit) {
|
|
12162
|
-
return
|
|
12163
|
-
|
|
13394
|
+
return db.transaction(async (tx) => {
|
|
13395
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
|
|
12164
13396
|
UPDATE inbox_entries
|
|
12165
13397
|
SET status = 'delivered', delivered_at = NOW()
|
|
12166
13398
|
WHERE id IN (
|
|
@@ -12171,53 +13403,132 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
12171
13403
|
FOR UPDATE SKIP LOCKED
|
|
12172
13404
|
)
|
|
12173
13405
|
RETURNING *
|
|
12174
|
-
`);
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12187
|
-
|
|
12188
|
-
|
|
12189
|
-
|
|
12190
|
-
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
|
|
12194
|
-
|
|
12195
|
-
|
|
12196
|
-
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
|
|
12200
|
-
|
|
12201
|
-
|
|
12202
|
-
|
|
12203
|
-
|
|
12204
|
-
|
|
12205
|
-
|
|
12206
|
-
|
|
12207
|
-
|
|
12208
|
-
|
|
12209
|
-
chatId:
|
|
12210
|
-
|
|
12211
|
-
|
|
12212
|
-
|
|
12213
|
-
|
|
12214
|
-
|
|
12215
|
-
|
|
12216
|
-
|
|
12217
|
-
|
|
13406
|
+
`));
|
|
13407
|
+
});
|
|
13408
|
+
}
|
|
13409
|
+
/**
|
|
13410
|
+
* Shared payload assembler for already-claimed `inbox_entries` rows.
|
|
13411
|
+
*
|
|
13412
|
+
* Both the HTTP poll path (`pollInbox`) and the WS push path
|
|
13413
|
+
* (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
|
|
13414
|
+
* `status='delivered'`. Keeping the silent-context bundling in one place is
|
|
13415
|
+
* the only way to keep the two paths from drifting (proposal
|
|
13416
|
+
* hub-inbox-ws-data-plane §3.2 risk #1).
|
|
13417
|
+
*
|
|
13418
|
+
* Steps:
|
|
13419
|
+
* 1. Sort by `created_at` ASC (PG `RETURNING` does not guarantee order).
|
|
13420
|
+
* 2. For each trigger, collect silent context & bulk-ack stale silent rows.
|
|
13421
|
+
* 3. Fetch the trigger messages.
|
|
13422
|
+
* 4. Build wire payloads via the single dispatcher.
|
|
13423
|
+
*
|
|
13424
|
+
* Returns `[]` if `claimed` is empty.
|
|
13425
|
+
*/
|
|
13426
|
+
async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
13427
|
+
if (claimed.length === 0) return [];
|
|
13428
|
+
claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
13429
|
+
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
13430
|
+
const messageIds = claimed.map((e) => e.message_id);
|
|
13431
|
+
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
13432
|
+
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
13433
|
+
const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
|
|
13434
|
+
const msg = msgMap.get(entry.message_id);
|
|
13435
|
+
if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
|
|
13436
|
+
return {
|
|
13437
|
+
entryChatId: entry.chat_id,
|
|
13438
|
+
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
13439
|
+
message: {
|
|
13440
|
+
id: msg.id,
|
|
13441
|
+
chatId: msg.chatId,
|
|
13442
|
+
senderId: msg.senderId,
|
|
13443
|
+
format: msg.format,
|
|
13444
|
+
content: msg.content,
|
|
13445
|
+
metadata: msg.metadata,
|
|
13446
|
+
replyToInbox: msg.replyToInbox,
|
|
13447
|
+
replyToChat: msg.replyToChat,
|
|
13448
|
+
inReplyTo: msg.inReplyTo,
|
|
13449
|
+
source: msg.source,
|
|
13450
|
+
createdAt: msg.createdAt.toISOString()
|
|
13451
|
+
}
|
|
13452
|
+
};
|
|
13453
|
+
}));
|
|
13454
|
+
return claimed.map((entry, idx) => {
|
|
13455
|
+
const payload = payloads[idx];
|
|
13456
|
+
if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
|
|
13457
|
+
return {
|
|
13458
|
+
id: Number(entry.id),
|
|
13459
|
+
inboxId: entry.inbox_id,
|
|
13460
|
+
messageId: entry.message_id,
|
|
13461
|
+
chatId: entry.chat_id,
|
|
13462
|
+
status: entry.status,
|
|
13463
|
+
retryCount: entry.retry_count,
|
|
13464
|
+
createdAt: entry.created_at,
|
|
13465
|
+
deliveredAt: entry.delivered_at ?? null,
|
|
13466
|
+
ackedAt: entry.acked_at ?? null,
|
|
13467
|
+
message: payload
|
|
13468
|
+
};
|
|
12218
13469
|
});
|
|
12219
13470
|
}
|
|
12220
13471
|
/**
|
|
13472
|
+
* Realistic upper bound on rows a single NOTIFY references. The unique
|
|
13473
|
+
* constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
|
|
13474
|
+
* pair at one row per chatId; the only way to exceed 1 today is the replyTo
|
|
13475
|
+
* cross-chat path (`message.ts` writes a second row keyed by the original's
|
|
13476
|
+
* `replyToChat`). 8 leaves headroom for any future fan-out variant without
|
|
13477
|
+
* requiring a schema change here.
|
|
13478
|
+
*/
|
|
13479
|
+
const PUSH_CLAIM_BATCH_LIMIT = 8;
|
|
13480
|
+
/**
|
|
13481
|
+
* WS-push path: atomically claim every pending entry the just-fired
|
|
13482
|
+
* `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
|
|
13483
|
+
*
|
|
13484
|
+
* Returns `[]` if no row matches — benign race with HTTP poll or another
|
|
13485
|
+
* server instance that already claimed the entry. NOTIFY is fire-and-forget
|
|
13486
|
+
* (proposal §3.2).
|
|
13487
|
+
*
|
|
13488
|
+
* Why an array, not a single row: `sendMessage` can write **two** rows for
|
|
13489
|
+
* the same `(inbox, messageId)` pair when the recipient is both a chat
|
|
13490
|
+
* participant and the `replyToInbox` of an earlier message — the unique key
|
|
13491
|
+
* is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
|
|
13492
|
+
* old `LIMIT 1` shape would only push the first; the second sat `pending`
|
|
13493
|
+
* until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
|
|
13494
|
+
* that gap and keeps push/poll behaviour interchangeable.
|
|
13495
|
+
*/
|
|
13496
|
+
async function claimAndBuildForPush(db, inboxId, messageId) {
|
|
13497
|
+
return withSpan("inbox.deliver.push", {
|
|
13498
|
+
"inbox.id": inboxId,
|
|
13499
|
+
"message.id": messageId
|
|
13500
|
+
}, () => db.transaction(async (tx) => {
|
|
13501
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
|
|
13502
|
+
UPDATE inbox_entries
|
|
13503
|
+
SET status = 'delivered', delivered_at = NOW()
|
|
13504
|
+
WHERE id IN (
|
|
13505
|
+
SELECT id FROM inbox_entries
|
|
13506
|
+
WHERE inbox_id = ${inboxId}
|
|
13507
|
+
AND message_id = ${messageId}
|
|
13508
|
+
AND status = 'pending'
|
|
13509
|
+
AND notify = true
|
|
13510
|
+
ORDER BY created_at
|
|
13511
|
+
LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
|
|
13512
|
+
FOR UPDATE SKIP LOCKED
|
|
13513
|
+
)
|
|
13514
|
+
RETURNING *
|
|
13515
|
+
`));
|
|
13516
|
+
}));
|
|
13517
|
+
}
|
|
13518
|
+
/**
|
|
13519
|
+
* WS-push backlog path: on agent rebind (or once an in-flight slot frees up
|
|
13520
|
+
* after an ack), drain up to `limit` pending `notify=true` entries oldest-
|
|
13521
|
+
* first and assemble wire payloads. Identical claim shape to the HTTP poll
|
|
13522
|
+
* path — they are intentionally interchangeable so a hot-path bug fixed in
|
|
13523
|
+
* one shows up in the other (proposal §3.3 / §3.5).
|
|
13524
|
+
*/
|
|
13525
|
+
async function claimBacklogForPush(db, inboxId, limit) {
|
|
13526
|
+
return withSpan("inbox.deliver.backlog", {
|
|
13527
|
+
"inbox.id": inboxId,
|
|
13528
|
+
"inbox.backlog.limit": limit
|
|
13529
|
+
}, () => pollInboxInner(db, inboxId, limit));
|
|
13530
|
+
}
|
|
13531
|
+
/**
|
|
12221
13532
|
* Per claimed trigger: SELECT silent (notify=false) pending rows in the same
|
|
12222
13533
|
* chat that occurred between the previous trigger in this batch (or beginning
|
|
12223
13534
|
* of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
|
|
@@ -12293,6 +13604,26 @@ async function ackEntry$2(db, entryId, inboxId) {
|
|
|
12293
13604
|
return entry;
|
|
12294
13605
|
});
|
|
12295
13606
|
}
|
|
13607
|
+
/**
|
|
13608
|
+
* Ack a delivered entry from the WS data plane, scoped to the inboxes the
|
|
13609
|
+
* connected socket has bound. Returns the acked row on success, `null` if no
|
|
13610
|
+
* row matches — a benign outcome the caller should ignore (the entry may
|
|
13611
|
+
* have already been acked, timed out, or never belonged to this socket).
|
|
13612
|
+
*
|
|
13613
|
+
* Distinct from {@link ackEntry} so the WS path can ack without trusting an
|
|
13614
|
+
* `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
|
|
13615
|
+
* are eligible. Empty `inboxIds` short-circuits to `null`.
|
|
13616
|
+
*/
|
|
13617
|
+
async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
|
|
13618
|
+
if (inboxIds.length === 0) return null;
|
|
13619
|
+
return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
|
|
13620
|
+
const [entry] = await db.update(inboxEntries).set({
|
|
13621
|
+
status: "acked",
|
|
13622
|
+
ackedAt: /* @__PURE__ */ new Date()
|
|
13623
|
+
}).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
|
|
13624
|
+
return entry ?? null;
|
|
13625
|
+
});
|
|
13626
|
+
}
|
|
12296
13627
|
async function renewEntry(db, entryId, inboxId) {
|
|
12297
13628
|
const [entry] = await db.update(inboxEntries).set({ deliveredAt: /* @__PURE__ */ new Date() }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
|
|
12298
13629
|
if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
|
|
@@ -12541,6 +13872,27 @@ async function agentTaskRoutes(app) {
|
|
|
12541
13872
|
return getTaskHealth(app.db, request.params.taskId, identity.organizationId);
|
|
12542
13873
|
});
|
|
12543
13874
|
}
|
|
13875
|
+
/**
|
|
13876
|
+
* Default per-agent in-flight cap when `server.inbox.maxInFlightPerAgent` is
|
|
13877
|
+
* unset. Mirrors the schema default so a hub running without an explicit
|
|
13878
|
+
* `inbox` block still gets reasonable backpressure once `wsDataPlane` is
|
|
13879
|
+
* flipped on. See proposal hub-inbox-ws-data-plane §3.5.
|
|
13880
|
+
*/
|
|
13881
|
+
const DEFAULT_INBOX_MAX_IN_FLIGHT_PER_AGENT = 32;
|
|
13882
|
+
/**
|
|
13883
|
+
* Hard cap on entries scanned in a single backlog drain so a recovering
|
|
13884
|
+
* client doesn't trigger an arbitrarily large transaction or burst of
|
|
13885
|
+
* frames. Anything beyond this stays `pending` and gets picked up by
|
|
13886
|
+
* subsequent post-ack drains. Same constant covers both the agent:bound
|
|
13887
|
+
* recovery path and the post-ack top-up.
|
|
13888
|
+
*
|
|
13889
|
+
* Lower than proposal §3.3's 500 on purpose: the actual limit per drain is
|
|
13890
|
+
* `min(remainingInFlightBudget, INBOX_BACKLOG_BATCH_LIMIT)`, so with a
|
|
13891
|
+
* default cap of 32 the drain SQL never asks for more than ~32 anyway.
|
|
13892
|
+
* Subsequent NOTIFYs and post-ack top-ups continue draining without a
|
|
13893
|
+
* single-transaction megabatch.
|
|
13894
|
+
*/
|
|
13895
|
+
const INBOX_BACKLOG_BATCH_LIMIT = 50;
|
|
12544
13896
|
const wsMessageSchema = z.object({
|
|
12545
13897
|
type: z.string(),
|
|
12546
13898
|
agentId: z.string().optional(),
|
|
@@ -12585,6 +13937,7 @@ function sendRejected(socket, ref, reason) {
|
|
|
12585
13937
|
function clientWsRoutes(notifier, instanceId) {
|
|
12586
13938
|
return async (app) => {
|
|
12587
13939
|
const jwtSecretBytes = new TextEncoder().encode(app.config.secrets.jwtSecret);
|
|
13940
|
+
const inboxMaxInFlightPerAgent = app.config.inbox?.maxInFlightPerAgent ?? DEFAULT_INBOX_MAX_IN_FLIGHT_PER_AGENT;
|
|
12588
13941
|
app.get("/client", {
|
|
12589
13942
|
websocket: true,
|
|
12590
13943
|
config: { otel: false }
|
|
@@ -12594,6 +13947,157 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12594
13947
|
let clientId = null;
|
|
12595
13948
|
let authExpiryTimer = null;
|
|
12596
13949
|
const boundAgents = /* @__PURE__ */ new Map();
|
|
13950
|
+
/**
|
|
13951
|
+
* Whether the connected client opted into the WS inbox data plane via
|
|
13952
|
+
* `client:register.wireCapabilities.wsInboxDeliver`. Set per-socket
|
|
13953
|
+
* because client SDKs are upgraded independently — an old client
|
|
13954
|
+
* connecting to a new server must keep receiving the legacy
|
|
13955
|
+
* `new_message` doorbell + HTTP poll path (proposal §3.6).
|
|
13956
|
+
*/
|
|
13957
|
+
let clientWantsWsInboxDeliver = false;
|
|
13958
|
+
/**
|
|
13959
|
+
* Per-agent in-flight `inbox:deliver` counter for backpressure. Lives on
|
|
13960
|
+
* the socket — when the WS closes it goes with it; that's intentional,
|
|
13961
|
+
* because re-counting on a fresh connection would bias the cap against
|
|
13962
|
+
* a healthy reconnect (proposal §3.5).
|
|
13963
|
+
*/
|
|
13964
|
+
const inboxInFlight = /* @__PURE__ */ new Map();
|
|
13965
|
+
function pushUseWsDataPlane() {
|
|
13966
|
+
return clientWantsWsInboxDeliver;
|
|
13967
|
+
}
|
|
13968
|
+
/**
|
|
13969
|
+
* Returns `false` when the socket has already moved out of `OPEN` —
|
|
13970
|
+
* the only failure mode the caller can observe synchronously.
|
|
13971
|
+
*
|
|
13972
|
+
* Note: `ws.send` is fire-and-forget; a buffered frame that fails
|
|
13973
|
+
* to actually flush (TCP slow-close, internal queue full) does NOT
|
|
13974
|
+
* surface here. That class of loss is recovered by the 300s timeout
|
|
13975
|
+
* reaper rolling the entry back to `pending` (§3.7). If you ever
|
|
13976
|
+
* need flush-level confirmation, switch to the `ws.send(frame, cb)`
|
|
13977
|
+
* callback form (see `notifier.ts pushFrameToInbox`).
|
|
13978
|
+
*/
|
|
13979
|
+
function sendInboxDeliverFrame(entry) {
|
|
13980
|
+
if (socket.readyState !== socket.OPEN) return false;
|
|
13981
|
+
const frame = {
|
|
13982
|
+
type: "inbox:deliver",
|
|
13983
|
+
entryId: entry.id,
|
|
13984
|
+
inboxId: entry.inboxId,
|
|
13985
|
+
chatId: entry.chatId,
|
|
13986
|
+
message: entry.message
|
|
13987
|
+
};
|
|
13988
|
+
const validated = inboxDeliverFrameSchema$1.safeParse(frame);
|
|
13989
|
+
if (!validated.success) app.log.error({
|
|
13990
|
+
entryId: entry.id,
|
|
13991
|
+
inboxId: entry.inboxId,
|
|
13992
|
+
issues: validated.error.issues.map((i) => ({
|
|
13993
|
+
path: i.path.join("."),
|
|
13994
|
+
code: i.code,
|
|
13995
|
+
message: i.message
|
|
13996
|
+
}))
|
|
13997
|
+
}, "inbox:deliver frame failed self-validation — wire shape drift");
|
|
13998
|
+
socket.send(JSON.stringify(frame));
|
|
13999
|
+
return true;
|
|
14000
|
+
}
|
|
14001
|
+
/**
|
|
14002
|
+
* Build the per-socket push handler bound to a specific agent. Closes
|
|
14003
|
+
* over `agentId`, `inboxId`, the socket, and the in-flight counter.
|
|
14004
|
+
*
|
|
14005
|
+
* Backpressure: when the agent is at-cap we drop the NOTIFY (entry
|
|
14006
|
+
* stays `pending` server-side) and a debug log records the drop so
|
|
14007
|
+
* staging can correlate "messages slow" reports against cap hits.
|
|
14008
|
+
* The dropped row is replayed by `drainBacklogForAgent` once an ack
|
|
14009
|
+
* frees a slot, or by the next NOTIFY when we're back below cap (§3.5).
|
|
14010
|
+
*
|
|
14011
|
+
* Multi-row claims: a single `(inboxId, messageId)` pair can map to
|
|
14012
|
+
* more than one `inbox_entries` row (replyTo cross-chat case writes
|
|
14013
|
+
* a second row with a different chatId). We push every row claimed
|
|
14014
|
+
* by this NOTIFY in one go — see `claimAndBuildForPush`.
|
|
14015
|
+
*
|
|
14016
|
+
* The cap is intentionally **soft**: claim happens after the gate
|
|
14017
|
+
* check, so an N>1 claim can nudge in-flight slightly past
|
|
14018
|
+
* `inboxMaxInFlightPerAgent`. N is bounded by the
|
|
14019
|
+
* `(inbox_id, message_id, chat_id)` unique constraint (≤2 today),
|
|
14020
|
+
* so worst-case overshoot is small and the memory headroom in §3.5's
|
|
14021
|
+
* 64MB estimate covers it.
|
|
14022
|
+
*/
|
|
14023
|
+
function makeInboxPushHandler(agentId, inboxId) {
|
|
14024
|
+
return async (messageId) => {
|
|
14025
|
+
const current = inboxInFlight.get(agentId) ?? 0;
|
|
14026
|
+
if (current >= inboxMaxInFlightPerAgent) {
|
|
14027
|
+
app.log.debug({
|
|
14028
|
+
agentId,
|
|
14029
|
+
inboxId,
|
|
14030
|
+
messageId,
|
|
14031
|
+
inFlightCount: current,
|
|
14032
|
+
cap: inboxMaxInFlightPerAgent
|
|
14033
|
+
}, "inbox push: at cap, dropping NOTIFY (will replay via post-ack drain)");
|
|
14034
|
+
return;
|
|
14035
|
+
}
|
|
14036
|
+
let entries;
|
|
14037
|
+
try {
|
|
14038
|
+
entries = await claimAndBuildForPush(app.db, inboxId, messageId);
|
|
14039
|
+
} catch (err) {
|
|
14040
|
+
app.log.error({
|
|
14041
|
+
err,
|
|
14042
|
+
inboxId,
|
|
14043
|
+
messageId,
|
|
14044
|
+
agentId
|
|
14045
|
+
}, "claimAndBuildForPush failed");
|
|
14046
|
+
return;
|
|
14047
|
+
}
|
|
14048
|
+
if (entries.length === 0) return;
|
|
14049
|
+
for (const entry of entries) {
|
|
14050
|
+
inboxInFlight.set(agentId, (inboxInFlight.get(agentId) ?? 0) + 1);
|
|
14051
|
+
if (!sendInboxDeliverFrame(entry)) {
|
|
14052
|
+
inboxInFlight.set(agentId, Math.max(0, (inboxInFlight.get(agentId) ?? 1) - 1));
|
|
14053
|
+
return;
|
|
14054
|
+
}
|
|
14055
|
+
}
|
|
14056
|
+
};
|
|
14057
|
+
}
|
|
14058
|
+
/**
|
|
14059
|
+
* Drain up to `INBOX_BACKLOG_BATCH_LIMIT` pending entries for an agent
|
|
14060
|
+
* over the current WS, capped by the remaining in-flight budget so a
|
|
14061
|
+
* full drain stays within the per-agent backpressure cap (§3.3, §3.5).
|
|
14062
|
+
*
|
|
14063
|
+
* Used in two places:
|
|
14064
|
+
* 1. Right after `agent:bound` — covers reconnects where NOTIFYs
|
|
14065
|
+
* were dropped while the socket was offline.
|
|
14066
|
+
* 2. Right after an `inbox:ack` — top up the in-flight slot just
|
|
14067
|
+
* freed, in case the previous NOTIFY was dropped at-cap.
|
|
14068
|
+
*
|
|
14069
|
+
* The cap is **soft**: this function reads `slotsFree` once before the
|
|
14070
|
+
* `claimBacklogForPush` round-trip, and a NOTIFY-driven push handler
|
|
14071
|
+
* may increment the counter concurrently. In the worst case in-flight
|
|
14072
|
+
* temporarily exceeds the cap by the number of concurrent pushes. With
|
|
14073
|
+
* the default cap of 32 and N ≤ 2 per push handler invocation, the
|
|
14074
|
+
* memory headroom in §3.5's 64MB estimate covers this.
|
|
14075
|
+
*/
|
|
14076
|
+
async function drainBacklogForAgent(agentId, inboxId) {
|
|
14077
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
14078
|
+
const slotsFree = inboxMaxInFlightPerAgent - (inboxInFlight.get(agentId) ?? 0);
|
|
14079
|
+
if (slotsFree <= 0) return;
|
|
14080
|
+
const limit = Math.min(slotsFree, INBOX_BACKLOG_BATCH_LIMIT);
|
|
14081
|
+
let entries;
|
|
14082
|
+
try {
|
|
14083
|
+
entries = await claimBacklogForPush(app.db, inboxId, limit);
|
|
14084
|
+
} catch (err) {
|
|
14085
|
+
app.log.error({
|
|
14086
|
+
err,
|
|
14087
|
+
agentId,
|
|
14088
|
+
inboxId,
|
|
14089
|
+
limit
|
|
14090
|
+
}, "claimBacklogForPush failed");
|
|
14091
|
+
return;
|
|
14092
|
+
}
|
|
14093
|
+
for (const entry of entries) {
|
|
14094
|
+
inboxInFlight.set(agentId, (inboxInFlight.get(agentId) ?? 0) + 1);
|
|
14095
|
+
if (!sendInboxDeliverFrame(entry)) {
|
|
14096
|
+
inboxInFlight.set(agentId, Math.max(0, (inboxInFlight.get(agentId) ?? 1) - 1));
|
|
14097
|
+
return;
|
|
14098
|
+
}
|
|
14099
|
+
}
|
|
14100
|
+
}
|
|
12597
14101
|
const sessionOpQueues = /* @__PURE__ */ new Map();
|
|
12598
14102
|
function chainSessionOp(agentId, chatId, op) {
|
|
12599
14103
|
const key = `${agentId}:${chatId}`;
|
|
@@ -12698,7 +14202,8 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12698
14202
|
socket.send(JSON.stringify({
|
|
12699
14203
|
type: "server:welcome",
|
|
12700
14204
|
serverCommandVersion: app.commandVersion,
|
|
12701
|
-
serverTimeMs: Date.now()
|
|
14205
|
+
serverTimeMs: Date.now(),
|
|
14206
|
+
capabilities: { wsInboxDeliver: true }
|
|
12702
14207
|
}));
|
|
12703
14208
|
} catch (err) {
|
|
12704
14209
|
const message = err instanceof Error ? err.message : "auth failure";
|
|
@@ -12715,6 +14220,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12715
14220
|
try {
|
|
12716
14221
|
if (type === "client:register") {
|
|
12717
14222
|
const data = clientRegisterSchema.parse(msg);
|
|
14223
|
+
clientWantsWsInboxDeliver = data.wireCapabilities?.wsInboxDeliver === true;
|
|
12718
14224
|
try {
|
|
12719
14225
|
await registerClient(app.db, {
|
|
12720
14226
|
clientId: data.clientId,
|
|
@@ -12751,7 +14257,8 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12751
14257
|
agentId: agent.uuid,
|
|
12752
14258
|
name: agent.name,
|
|
12753
14259
|
displayName: agent.displayName,
|
|
12754
|
-
agentType: agent.type
|
|
14260
|
+
agentType: agent.type,
|
|
14261
|
+
runtimeProvider: agent.runtimeProvider
|
|
12755
14262
|
});
|
|
12756
14263
|
if (!parsed.success) {
|
|
12757
14264
|
app.log.warn({
|
|
@@ -12787,6 +14294,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12787
14294
|
inboxId: agents.inboxId,
|
|
12788
14295
|
status: agents.status,
|
|
12789
14296
|
clientId: agents.clientId,
|
|
14297
|
+
runtimeProvider: agents.runtimeProvider,
|
|
12790
14298
|
clientUserId: clients.userId,
|
|
12791
14299
|
managerUserId: members.userId
|
|
12792
14300
|
}).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).leftJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
|
|
@@ -12821,6 +14329,10 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12821
14329
|
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
|
|
12822
14330
|
return;
|
|
12823
14331
|
}
|
|
14332
|
+
if (bindRequest.runtimeType !== agent.runtimeProvider) {
|
|
14333
|
+
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.RUNTIME_PROVIDER_MISMATCH);
|
|
14334
|
+
return;
|
|
14335
|
+
}
|
|
12824
14336
|
await bindAgent(app.db, agent.id, {
|
|
12825
14337
|
clientId,
|
|
12826
14338
|
instanceId,
|
|
@@ -12832,7 +14344,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12832
14344
|
agentId: agent.id,
|
|
12833
14345
|
inboxId: agent.inboxId
|
|
12834
14346
|
});
|
|
12835
|
-
|
|
14347
|
+
const wsPushActive = pushUseWsDataPlane();
|
|
14348
|
+
if (wsPushActive) notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
|
|
14349
|
+
else notifier.subscribe(agent.inboxId, socket);
|
|
12836
14350
|
socket.send(JSON.stringify({
|
|
12837
14351
|
type: "agent:bound",
|
|
12838
14352
|
ref,
|
|
@@ -12840,6 +14354,12 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12840
14354
|
displayName: agent.displayName,
|
|
12841
14355
|
agentType: agent.type
|
|
12842
14356
|
}));
|
|
14357
|
+
if (wsPushActive) drainBacklogForAgent(agent.id, agent.inboxId).catch((err) => {
|
|
14358
|
+
app.log.error({
|
|
14359
|
+
err,
|
|
14360
|
+
agentId: agent.id
|
|
14361
|
+
}, "post-bind backlog drain crashed");
|
|
14362
|
+
});
|
|
12843
14363
|
} else if (type === "agent:unbind") {
|
|
12844
14364
|
const agentId = parsed.data.agentId;
|
|
12845
14365
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -12854,6 +14374,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12854
14374
|
await unbindAgent(app.db, agentId);
|
|
12855
14375
|
unbindAgentFromClient(agentId);
|
|
12856
14376
|
boundAgents.delete(agentId);
|
|
14377
|
+
inboxInFlight.delete(agentId);
|
|
12857
14378
|
socket.send(JSON.stringify({
|
|
12858
14379
|
type: "agent:unbound",
|
|
12859
14380
|
agentId
|
|
@@ -12955,6 +14476,35 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
12955
14476
|
}
|
|
12956
14477
|
const payload = sessionCompletionMessageSchema.parse(msg);
|
|
12957
14478
|
if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
|
|
14479
|
+
} else if (type === "inbox:ack") {
|
|
14480
|
+
const payloadResult = inboxAckFrameSchema.safeParse(msg);
|
|
14481
|
+
if (!payloadResult.success) {
|
|
14482
|
+
socket.send(JSON.stringify({
|
|
14483
|
+
type: "error",
|
|
14484
|
+
message: "Malformed inbox:ack frame"
|
|
14485
|
+
}));
|
|
14486
|
+
return;
|
|
14487
|
+
}
|
|
14488
|
+
const { entryId } = payloadResult.data;
|
|
14489
|
+
try {
|
|
14490
|
+
const ackedEntry = await ackEntryByIdForBoundAgents(app.db, entryId, [...boundAgents.values()].map((a) => a.inboxId));
|
|
14491
|
+
if (!ackedEntry) return;
|
|
14492
|
+
const owner = [...boundAgents.values()].find((a) => a.inboxId === ackedEntry.inboxId);
|
|
14493
|
+
if (owner) {
|
|
14494
|
+
inboxInFlight.set(owner.agentId, Math.max(0, (inboxInFlight.get(owner.agentId) ?? 1) - 1));
|
|
14495
|
+
drainBacklogForAgent(owner.agentId, owner.inboxId).catch((err) => {
|
|
14496
|
+
app.log.error({
|
|
14497
|
+
err,
|
|
14498
|
+
agentId: owner.agentId
|
|
14499
|
+
}, "post-ack backlog drain crashed");
|
|
14500
|
+
});
|
|
14501
|
+
}
|
|
14502
|
+
} catch (err) {
|
|
14503
|
+
app.log.error({
|
|
14504
|
+
err,
|
|
14505
|
+
entryId
|
|
14506
|
+
}, "inbox:ack handling failed");
|
|
14507
|
+
}
|
|
12958
14508
|
} else if (type === "heartbeat") {
|
|
12959
14509
|
if (clientId) {
|
|
12960
14510
|
await heartbeatClient(app.db, clientId);
|
|
@@ -13169,7 +14719,8 @@ const authIdentities = pgTable("auth_identities", {
|
|
|
13169
14719
|
}, (table) => [
|
|
13170
14720
|
unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
|
|
13171
14721
|
index("idx_auth_identities_user").on(table.userId),
|
|
13172
|
-
index("idx_auth_identities_email").on(table.email)
|
|
14722
|
+
index("idx_auth_identities_email").on(table.email),
|
|
14723
|
+
uniqueIndex("uq_auth_identities_user_github").on(table.userId).where(sql`provider = 'github'`)
|
|
13173
14724
|
]);
|
|
13174
14725
|
/**
|
|
13175
14726
|
* Find or create the user backing a GitHub OAuth identity. Idempotent —
|
|
@@ -13342,19 +14893,20 @@ function sanitizeAgentName(login) {
|
|
|
13342
14893
|
return login.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "user";
|
|
13343
14894
|
}
|
|
13344
14895
|
/**
|
|
13345
|
-
* Create a fresh
|
|
13346
|
-
*
|
|
14896
|
+
* Create a fresh default team org for a brand-new user, plus the matching
|
|
14897
|
+
* admin membership + 1:1 human agent. Slug strategy:
|
|
13347
14898
|
*
|
|
13348
|
-
* - First try: `${login}
|
|
14899
|
+
* - First try: `${login}` (lowercased, sanitized)
|
|
13349
14900
|
* - On collision: append a 4-char hex disambiguator
|
|
13350
14901
|
*
|
|
13351
|
-
*
|
|
13352
|
-
*
|
|
13353
|
-
*
|
|
14902
|
+
* Display name is the user's GitHub real name (or login as fallback). No
|
|
14903
|
+
* "Personal Team" suffix — the user might invite teammates later, and we
|
|
14904
|
+
* don't want a label that reads like a private sandbox to be the team name
|
|
14905
|
+
* other members see. Users rename freely via Settings.
|
|
13354
14906
|
*/
|
|
13355
14907
|
async function createPersonalTeam(db, input) {
|
|
13356
|
-
const baseSlug = sanitizeOrgSlug(
|
|
13357
|
-
const displayName =
|
|
14908
|
+
const baseSlug = sanitizeOrgSlug(input.loginSeed);
|
|
14909
|
+
const displayName = input.userDisplayName;
|
|
13358
14910
|
const orgId = uuidv7();
|
|
13359
14911
|
return {
|
|
13360
14912
|
organizationId: orgId,
|
|
@@ -13640,9 +15192,7 @@ async function githubOauthRoutes(app) {
|
|
|
13640
15192
|
return completeOauthFlow(app, request, reply, profile, next);
|
|
13641
15193
|
});
|
|
13642
15194
|
app.get("/dev-callback", async (request, reply) => {
|
|
13643
|
-
|
|
13644
|
-
const devEnabled = oauthCfg?.devCallbackEnabled === true;
|
|
13645
|
-
if (isProd || !devEnabled) return reply.status(404).send({ error: "Not found" });
|
|
15195
|
+
if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
|
|
13646
15196
|
const params = githubDevCallbackQuerySchema.parse(request.query);
|
|
13647
15197
|
const next = safeRedirectPath(params.next ?? null);
|
|
13648
15198
|
return completeOauthFlow(app, request, reply, {
|
|
@@ -14046,7 +15596,7 @@ async function inferWizardStep(app, m) {
|
|
|
14046
15596
|
* landing page.
|
|
14047
15597
|
*/
|
|
14048
15598
|
async function publicInvitePreviewRoute(app) {
|
|
14049
|
-
const { previewInvitation } = await import("./invitation-
|
|
15599
|
+
const { previewInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
|
|
14050
15600
|
app.get("/:token/preview", async (request, reply) => {
|
|
14051
15601
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
14052
15602
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -14076,7 +15626,7 @@ async function adminInvitationRoutes(app) {
|
|
|
14076
15626
|
const m = requireMember(request);
|
|
14077
15627
|
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
14078
15628
|
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
14079
|
-
const { rotateInvitation } = await import("./invitation-
|
|
15629
|
+
const { rotateInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
|
|
14080
15630
|
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
14081
15631
|
return {
|
|
14082
15632
|
id: inv.id,
|
|
@@ -15443,6 +16993,7 @@ function createConfigService(opts) {
|
|
|
15443
16993
|
*/
|
|
15444
16994
|
function applyPatch(current, patch) {
|
|
15445
16995
|
return {
|
|
16996
|
+
kind: current.kind,
|
|
15446
16997
|
prompt: patch.prompt ?? current.prompt,
|
|
15447
16998
|
model: patch.model ?? current.model,
|
|
15448
16999
|
mcpServers: patch.mcpServers ?? current.mcpServers,
|
|
@@ -15468,13 +17019,26 @@ function createConfigService(opts) {
|
|
|
15468
17019
|
async function readRow(agentId) {
|
|
15469
17020
|
const [row] = await db.select().from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
|
|
15470
17021
|
if (!row) throw new NotFoundError(`Agent config "${agentId}" not found`);
|
|
15471
|
-
|
|
17022
|
+
const payload = agentRuntimeConfigPayloadSchema$1.parse(row.payload);
|
|
17023
|
+
return {
|
|
17024
|
+
...row,
|
|
17025
|
+
payload
|
|
17026
|
+
};
|
|
17027
|
+
}
|
|
17028
|
+
async function readRuntimeProviderFor(agentId) {
|
|
17029
|
+
const [row] = await db.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, agentId)).limit(1);
|
|
17030
|
+
if (!row) throw new NotFoundError(`Agent "${agentId}" not found`);
|
|
17031
|
+
return row.runtimeProvider;
|
|
15472
17032
|
}
|
|
15473
17033
|
async function commitWrite(agentId, patch, expectedVersion, updatedBy) {
|
|
15474
17034
|
const current = await readRow(agentId);
|
|
15475
17035
|
if (current.version !== expectedVersion) throw new ConflictError(`Agent config "${agentId}" version mismatch: expected ${expectedVersion}, got ${current.version}`);
|
|
15476
|
-
const
|
|
15477
|
-
const
|
|
17036
|
+
const provider = await readRuntimeProviderFor(agentId);
|
|
17037
|
+
const synced = {
|
|
17038
|
+
...applyPatch(current.payload, patch),
|
|
17039
|
+
kind: provider
|
|
17040
|
+
};
|
|
17041
|
+
const validated = agentRuntimeConfigPayloadSchema$1.parse(synced);
|
|
15478
17042
|
const [updated] = await db.update(agentConfigs).set({
|
|
15479
17043
|
version: sql`${agentConfigs.version} + 1`,
|
|
15480
17044
|
payload: validated,
|
|
@@ -15581,7 +17145,12 @@ function createConfigService(opts) {
|
|
|
15581
17145
|
},
|
|
15582
17146
|
async dryRun(agentId, patch) {
|
|
15583
17147
|
const row = await readRow(agentId);
|
|
15584
|
-
const
|
|
17148
|
+
const provider = await readRuntimeProviderFor(agentId);
|
|
17149
|
+
const synced = {
|
|
17150
|
+
...applyPatch(row.payload, patch),
|
|
17151
|
+
kind: provider
|
|
17152
|
+
};
|
|
17153
|
+
const next = agentRuntimeConfigPayloadSchema$1.parse(synced);
|
|
15585
17154
|
const diff = computeDiff(row.payload, next);
|
|
15586
17155
|
return {
|
|
15587
17156
|
current: {
|
|
@@ -16672,4 +18241,4 @@ function registerSaaSConnectCommand(program) {
|
|
|
16672
18241
|
});
|
|
16673
18242
|
}
|
|
16674
18243
|
//#endregion
|
|
16675
|
-
export {
|
|
18244
|
+
export { status as $, checkServerHealth as A, isDockerAvailable as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, installClientService as F, createOwner as G, ClientRuntime as H, isServiceSupported as I, fail as J, hasUser as K, resolveCliInvocation as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, getClientServiceStatus as P, setJsonMode as Q, uninstallClientService as R, runMigrations as S, checkClientConfig as T, handleClientOrgMismatch as U, stopPostgres as V, rotateClientIdWithBackup as W, blank as X, success as Y, print as Z, onboardCreate as _, declineUpdate as a, probeCapabilities as at, createApiNameResolver as b, COMMAND_VERSION as c, isInteractive as d, ClientOrgMismatchError as et, promptAddAgent as f, onboardCheck as g, loadOnboardState as h, createExecuteUpdate as i, cleanWorkspaces as it, checkServerReachable as j, checkServerConfig as k, reconcileLocalRuntimeProviders as l, formatCheckReport as m, deriveHubUrlFromToken as n, SdkError as nt, promptUpdate as o, applyClientLoggerConfig as ot, promptMissingFields as p, resolveReplyToFromEnv as q, registerSaaSConnectCommand as r, SessionRegistry as rt, startServer as s, configureClientLoggerForService as st, HubUrlDerivationError as t, FirstTreeHubSDK as tt, uploadClientCapabilities as u, saveOnboardState as v, checkBackgroundService as w, migrateLocalAgentDirs as x, runHomeMigration as y, ensurePostgres as z };
|