@agent-team-foundation/first-tree-hub 0.12.6 → 0.12.8

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.
@@ -2,10 +2,10 @@ import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw
2
2
  import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
3
3
  import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-BCZC1ki6.mjs";
4
4
  import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
5
- import { A as delegateFeishuUserSchema, B as inboxPollQuerySchema, C as createAgentSchema, D as createOrgFromMeSchema, E as createMemberSchema, F as githubDevCallbackQuerySchema, G as listMeChatsQuerySchema, H as isRedactedEnvValue, I as githubStartQuerySchema, J as notificationQuerySchema, K as loginSchema, L as imageInlineContentSchema, N as githubAppInstallationClaimBodySchema, P as githubCallbackQuerySchema, R as inboxAckFrameSchema, S as createAdapterMappingSchema, St as wsAuthFrameSchema, T as createMeChatSchema, U as isReservedAgentName$1, V as isOrgSettingNamespace, W as joinByInvitationSchema, X as paginationQuerySchema, Y as onboardingEventSchema, Z as patchOnboardingSchema, _t as updateAgentSchema, a as AGENT_VISIBILITY, at as selfServiceFeishuBotSchema, b as contextTreeSnapshotSchema, bt as updateMemberSchema, c as ORG_SETTINGS_NAMESPACES$1, ct as sessionCompletionMessageSchema, d as addParticipantSchema, dt as sessionReconcileRequestSchema, et as rebindAgentSchema, f as agentBindRequestSchema, ft as sessionStateMessageSchema, g as chatMetadataSchema$1, gt as updateAgentRuntimeConfigSchema, h as agentTypeSchema$1, ht as updateAdapterConfigSchema, i as AGENT_STATUSES, j as dryRunAgentRuntimeConfigSchema, k as defaultRuntimeConfigPayload, l as WS_AUTH_FRAME_TIMEOUT_MS, lt as sessionEventMessageSchema, m as agentRuntimeConfigPayloadSchema$1, mt as submitQuestionAnswerSchema, n as AGENT_NAME_REGEX$1, nt as runtimeStateMessageSchema, ot as sendMessageSchema, p as agentPinnedMessageSchema$1, pt as stripCode, q as messageSourceSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as safeRedirectPath, s as MENTION_REGEX, st as sendToAgentSchema, t as AGENT_BIND_REJECT_REASONS, tt as refreshTokenSchema, u as addMeChatParticipantsSchema, ut as sessionEventSchema$1, v as clientRegisterSchema, vt as updateChatSchema, w as createChatSchema, x as createAdapterConfigSchema, xt as updateOrganizationSchema, y as connectTokenExchangeSchema, yt as updateClientCapabilitiesSchema, z as inboxDeliverFrameSchema$1 } from "./dist-xP6NpdMp.mjs";
5
+ import { $ as patchOnboardingSchema, A as defaultRuntimeConfigPayload, B as inboxDeliverFrameSchema$1, C as createAdapterMappingSchema, Ct as updateOrganizationSchema, D as createMemberSchema, E as createMeChatSchema, F as githubCallbackQuerySchema, G as joinByInvitationSchema, H as isOrgSettingNamespace, I as githubDevCallbackQuerySchema, J as messageSourceSchema$1, K as listMeChatsQuerySchema, L as githubStartQuerySchema, M as dryRunAgentRuntimeConfigSchema, O as createOrgFromMeSchema, P as githubAppInstallationClaimBodySchema, Q as patchChatEngagementSchema, R as imageInlineContentSchema, S as createAdapterConfigSchema, St as updateMemberSchema, T as createChatSchema, U as isRedactedEnvValue, V as inboxPollQuerySchema, W as isReservedAgentName$1, X as onboardingEventSchema, Y as notificationQuerySchema, Z as paginationQuerySchema, _ as chatMetadataSchema$1, _t as updateAdapterConfigSchema, a as AGENT_VISIBILITY, at as safeRedirectPath, b as connectTokenExchangeSchema, bt as updateChatSchema, c as MENTION_REGEX, ct as sendMessageSchema, d as addMeChatParticipantsSchema, dt as sessionEventMessageSchema, f as addParticipantSchema, ft as sessionEventSchema$1, g as agentTypeSchema$1, gt as submitQuestionAnswerSchema, h as agentRuntimeConfigPayloadSchema$1, ht as stripCode, i as AGENT_STATUSES, it as runtimeStateMessageSchema, j as delegateFeishuUserSchema, l as ORG_SETTINGS_NAMESPACES$1, lt as sendToAgentSchema, m as agentPinnedMessageSchema$1, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as CHAT_ENGAGEMENT_STATUSES, p as agentBindRequestSchema, pt as sessionReconcileRequestSchema, q as loginSchema, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, u as WS_AUTH_FRAME_TIMEOUT_MS, ut as sessionCompletionMessageSchema, vt as updateAgentRuntimeConfigSchema, w as createAgentSchema, wt as wsAuthFrameSchema, x as contextTreeSnapshotSchema, xt as updateClientCapabilitiesSchema, y as clientRegisterSchema, yt as updateAgentSchema, z as inboxAckFrameSchema } from "./dist-CnjqakXS.mjs";
6
6
  import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
7
7
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
- import { $ as messages, A as getOnlineCount, B as listActiveAgentsPinnedToClient, C as ensureCanJoin, D as getCachedAudience, E as getActivityOverview, F as invalidateChatAudience, G as listChatsForMember, H as listAgentsWithRuntime, I as joinAsParticipant, J as listMessages, K as listClients, L as joinChat, M as heartbeatClient, N as heartbeatInstance, O as getChatDetail, P as inboxEntries, Q as members, R as leaveAsParticipant, S as editMessage, T as findOrCreateDirectChat, U as listChatParticipantsWithNames, V as listAgentsManagedByUser, W as listChats, X as markStaleAgents, Z as markSupersededByChat, _ as clients, _t as touchAgent, a as agentVisibilityCondition, at as registerChatMessageDispatcher, b as deriveAuthState, bt as upsertSessionState, c as assertParticipant, ct as resetActivity, d as chatParticipants, dt as sendMessage, et as notifyRecipients, f as chatSubscriptions, ft as sendToAgent$1, g as cleanupStalePresence, gt as submitAnswer, h as cleanupStaleClients, ht as setRuntimeState, i as agentPresence, it as recomputeWatchersForMember, j as getPresence, k as getClient, l as bindAgent, lt as resolveChatMembership, m as claimClient, mt as setOffline, n as addParticipant, nt as recomputeChatWatchers, o as agents, ot as registerClient, p as chats, pt as serverInstances, q as listClientsForOrgAdmin, r as agentChatSessions, rt as recomputeWatchersForAgent, s as assertClientOwner, st as removeParticipant, t as addChatParticipants, tt as pendingQuestions, u as changeChatType, ut as retireClient, v as createChat, vt as unbindAgent, w as ensureParticipant$1, x as disconnectClient, y as createNotifier, yt as updateClientCapabilities, z as leaveChat } from "./client-B89AKi3Q-DAyGdQSq.mjs";
8
+ import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-bR8nwHaV-OxnjyKOk.mjs";
9
9
  import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
@@ -570,10 +570,15 @@ const runtimeStateSchema = z.enum([
570
570
  z.enum([
571
571
  "active",
572
572
  "suspended",
573
- "evicted"
573
+ "evicted",
574
+ "errored"
574
575
  ]);
575
576
  /** Wire-level states a client may report. `evicted` from a stale client is rejected. */
576
- const clientSessionStateSchema = z.enum(["active", "suspended"]);
577
+ const clientSessionStateSchema = z.enum([
578
+ "active",
579
+ "suspended",
580
+ "errored"
581
+ ]);
577
582
  z.object({
578
583
  chatId: z.string().min(1),
579
584
  state: clientSessionStateSchema
@@ -759,6 +764,12 @@ const chatTypeSchema = z.enum([
759
764
  "group",
760
765
  "thread"
761
766
  ]);
767
+ const chatEngagementStatusSchema = z.enum([
768
+ "active",
769
+ "archived",
770
+ "deleted"
771
+ ]);
772
+ z.object({ status: chatEngagementStatusSchema });
762
773
  z.object({
763
774
  type: chatTypeSchema,
764
775
  topic: z.string().max(500).optional(),
@@ -788,7 +799,8 @@ z.object({
788
799
  }).extend({
789
800
  participants: z.array(chatParticipantSchema),
790
801
  title: z.string(),
791
- firstMessagePreview: z.string().nullable()
802
+ firstMessagePreview: z.string().nullable(),
803
+ engagementStatus: chatEngagementStatusSchema
792
804
  });
793
805
  z.object({ topic: z.string().trim().max(500).nullable() });
794
806
  z.object({ agentId: z.string().min(1) });
@@ -1237,10 +1249,16 @@ const meChatFilterSchema = z.enum([
1237
1249
  "watching"
1238
1250
  ]);
1239
1251
  const meChatMembershipKindSchema = z.enum(["participant", "watching"]);
1252
+ const chatEngagementViewSchema = z.enum([
1253
+ "active",
1254
+ "archived",
1255
+ "all"
1256
+ ]);
1240
1257
  z.object({
1241
1258
  cursor: z.string().optional(),
1242
1259
  limit: z.coerce.number().int().min(1).max(200).default(50),
1243
- filter: meChatFilterSchema.default("all")
1260
+ filter: meChatFilterSchema.default("all"),
1261
+ engagement: chatEngagementViewSchema.default("active")
1244
1262
  });
1245
1263
  const meChatParticipantSchema = z.object({
1246
1264
  agentId: z.string(),
@@ -1258,7 +1276,8 @@ const meChatRowSchema = z.object({
1258
1276
  lastMessageAt: z.string().nullable(),
1259
1277
  lastMessagePreview: z.string().nullable(),
1260
1278
  unreadMentionCount: z.number().int(),
1261
- canReply: z.boolean()
1279
+ canReply: z.boolean(),
1280
+ engagementStatus: chatEngagementStatusSchema
1262
1281
  });
1263
1282
  z.object({
1264
1283
  rows: z.array(meChatRowSchema),
@@ -3353,8 +3372,26 @@ function normalizePath(rawPath) {
3353
3372
  function shortHash(input) {
3354
3373
  return createHash("sha256").update(input).digest("hex").slice(0, 8);
3355
3374
  }
3356
- function deriveSessionBranchName(sessionKey, url) {
3357
- return `${SESSION_BRANCH_PREFIX}-${shortHash(sessionKey)}-${shortHash(url)}`;
3375
+ /**
3376
+ * Branch name a session's worktree attaches to. The hash inputs include the
3377
+ * agent dimension because `(chat, url)` alone is not unique: two agents that
3378
+ * share a chat each open their own worktree at
3379
+ * `<workspaces>/<agent>/<chatId>/...`, and git refuses to point two worktrees
3380
+ * at the same branch (`fatal: '<branch>' is already used by worktree at …`).
3381
+ * Hash inputs are joined with `:` so `(chatA, agentB)` cannot collide with
3382
+ * `(chatAB, "")`.
3383
+ *
3384
+ * The caller picks `agentName`. Prefer the operator-stable name
3385
+ * (`config.yaml::agents.<name>`); fall back to `agent.agentId` (a UUID,
3386
+ * globally unique) when the stable name isn't available. Anything stable
3387
+ * across `start` and `resume` for the same `(agent, chat)` pair will do —
3388
+ * the contract is "no collision with a peer agent in the same chat", not
3389
+ * "human-readable in the branch name".
3390
+ *
3391
+ * See docs/workspace-session-branch-collision-fix-design.md §3.2.
3392
+ */
3393
+ function deriveSessionBranchName(sessionKey, agentName, url) {
3394
+ return `${SESSION_BRANCH_PREFIX}-${shortHash(`${sessionKey}:${agentName}`)}-${shortHash(url)}`;
3358
3395
  }
3359
3396
  /**
3360
3397
  * A value is SHA-like when it's a 7–40 character hex string. Used to decide
@@ -3766,12 +3803,12 @@ function createGitMirrorManager(opts) {
3766
3803
  }
3767
3804
  });
3768
3805
  },
3769
- createWorktree({ url, ref, targetPath, sessionKey }) {
3806
+ createWorktree({ url, ref, targetPath, sessionKey, agentName }) {
3770
3807
  return withUrlLock(url, async () => {
3771
3808
  const mirror = mirrorDir(url);
3772
3809
  if (!existsSync(join(mirror, "HEAD"))) throw new GitMirrorError(`Cannot create worktree — no mirror exists for "${url}"`);
3773
3810
  const absTarget = resolve(targetPath);
3774
- const branchName = deriveSessionBranchName(sessionKey, url);
3811
+ const branchName = deriveSessionBranchName(sessionKey, agentName, url);
3775
3812
  if (existsSync(absTarget) && !isHubManagedWorktree(absTarget)) {
3776
3813
  log?.warn({
3777
3814
  gitUrl: url,
@@ -4112,15 +4149,53 @@ var InputController = class {
4112
4149
  };
4113
4150
  const DEFAULT_WORKSPACE_TTL_MS = 10080 * 60 * 1e3;
4114
4151
  /**
4152
+ * Sentinel that flags "stage-2 of session bootstrap (git worktree
4153
+ * materialisation) completed successfully". Distinct from the
4154
+ * `FIRST_TREE_WORKSPACE_MARKER` (`.first-tree-workspace`) which is the
4155
+ * "agent workspace boundary" — Codex's `project_root_markers` uses that one
4156
+ * to stop walking up the filesystem when looking for `AGENTS.md`. Splitting
4157
+ * the two so the boundary marker can be written eagerly (stage 1) while the
4158
+ * completion sentinel only appears after stage 2 lets `acquireWorkspace`
4159
+ * detect half-baked directories from a previous failed start and self-heal.
4160
+ *
4161
+ * See docs/workspace-session-branch-collision-fix-design.md §3.4.
4162
+ */
4163
+ const INIT_COMPLETE_SENTINEL_REL = join(".agent", "init-complete");
4164
+ /**
4115
4165
  * Acquire a per-chat workspace directory.
4116
- * Creates the directory if it does not exist; returns the path if it does.
4166
+ *
4167
+ * Healing rule: if the directory exists AND carries the boundary marker
4168
+ * (`.first-tree-workspace`, written in stage 1) AND is missing the
4169
+ * completion sentinel (`.agent/init-complete`, written after stage 2), the
4170
+ * previous session start crashed between the two writes — wipe it so the
4171
+ * fresh start gets a clean slate. The boundary marker alone (without the
4172
+ * sentinel) is the unambiguous shape of a half-baked workspace: only stage 1
4173
+ * writes it, and only stage 2 writes the sentinel.
4117
4174
  */
4118
4175
  function acquireWorkspace(workspaceRoot, chatId) {
4119
4176
  const dir = join(workspaceRoot, chatId);
4177
+ if (existsSync(dir) && existsSync(join(dir, ".first-tree-workspace")) && !existsSync(join(dir, INIT_COMPLETE_SENTINEL_REL))) rmSync(dir, {
4178
+ recursive: true,
4179
+ force: true
4180
+ });
4120
4181
  mkdirSync(dir, { recursive: true });
4121
4182
  return dir;
4122
4183
  }
4123
4184
  /**
4185
+ * Write the stage-2 completion sentinel. Callers must invoke this AFTER all
4186
+ * pre-handler-spawn setup (workspace bootstrap, git worktrees, first-tree
4187
+ * integration) succeeded, so a process that crashes earlier leaves a
4188
+ * half-baked workspace the next acquireWorkspace can heal.
4189
+ */
4190
+ function markWorkspaceInitComplete(workspaceCwd) {
4191
+ const path = join(workspaceCwd, INIT_COMPLETE_SENTINEL_REL);
4192
+ mkdirSync(join(workspaceCwd, ".agent"), { recursive: true });
4193
+ writeFileSync(path, JSON.stringify({
4194
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
4195
+ schemaVersion: 1
4196
+ }), "utf-8");
4197
+ }
4198
+ /**
4124
4199
  * Clean stale workspace directories for an agent.
4125
4200
  *
4126
4201
  * A workspace is considered stale when:
@@ -4864,12 +4939,13 @@ const createClaudeCodeHandler = (config) => {
4864
4939
  const mirror = await gitMirrorManager.ensureMirror(repo.url);
4865
4940
  if (mirror.cloned) sessionCtx.log(`Git: cloned ${repo.url} in ${mirror.elapsedMs}ms`);
4866
4941
  await gitMirrorManager.fetchMirror(repo.url);
4942
+ const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
4867
4943
  if (existsSync(targetPath) && isHubWorktreeMarker(targetPath)) {
4868
4944
  sessionCtx.log(`Git: reusing existing worktree at ${localPath}`);
4869
4945
  ownedWorktrees.push({
4870
4946
  url: repo.url,
4871
4947
  path: targetPath,
4872
- branchName: deriveSessionBranchName(sessionCtx.chatId, repo.url)
4948
+ branchName: deriveSessionBranchName(sessionCtx.chatId, branchAgentKey, repo.url)
4873
4949
  });
4874
4950
  continue;
4875
4951
  }
@@ -4877,7 +4953,8 @@ const createClaudeCodeHandler = (config) => {
4877
4953
  url: repo.url,
4878
4954
  ref: repo.ref,
4879
4955
  targetPath,
4880
- sessionKey: sessionCtx.chatId
4956
+ sessionKey: sessionCtx.chatId,
4957
+ agentName: branchAgentKey
4881
4958
  });
4882
4959
  ownedWorktrees.push({
4883
4960
  url: repo.url,
@@ -4926,6 +5003,7 @@ const createClaudeCodeHandler = (config) => {
4926
5003
  runBootstrap(cwd, sessionCtx);
4927
5004
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
4928
5005
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5006
+ markWorkspaceInitComplete(cwd);
4929
5007
  sessionCtx.log(`Starting session (${claudeSessionId}), cwd=${cwd}, permissionMode=${config.permissionMode ?? "bypassPermissions"}`);
4930
5008
  spawnQuery(claudeSessionId, sessionCtx);
4931
5009
  const sdkMsg = await toSDKUserMessage(message, sessionCtx, claudeSessionId);
@@ -4938,9 +5016,10 @@ const createClaudeCodeHandler = (config) => {
4938
5016
  claudeSessionId = sessionId;
4939
5017
  retryCount = 0;
4940
5018
  cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
4941
- if (!existsSync(join(cwd, ".agent", "identity.json"))) runBootstrap(cwd, sessionCtx);
5019
+ if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx);
4942
5020
  const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
4943
5021
  await prepareGitWorktrees(cwd, payload, sessionCtx);
5022
+ markWorkspaceInitComplete(cwd);
4944
5023
  sessionCtx.log(`Resuming session (${sessionId}), cwd=${cwd}`);
4945
5024
  spawnQuery(sessionId, sessionCtx, sessionId);
4946
5025
  if (message) inputController?.push(await toSDKUserMessage(message, sessionCtx, sessionId));
@@ -5157,8 +5236,9 @@ const createCodexHandler = (config) => {
5157
5236
  function toCodexInput(message, sessionCtx) {
5158
5237
  return sessionCtx.formatInboundContent(message).then((text) => text);
5159
5238
  }
5160
- async function prepareGitWorktrees(payload, workspaceCwd, chatId) {
5239
+ async function prepareGitWorktrees(payload, workspaceCwd, sessionCtx) {
5161
5240
  if (!gitMirrorManager) return;
5241
+ const branchAgentKey = agentName ?? sessionCtx.agent.agentId;
5162
5242
  for (const repo of payload.gitRepos) {
5163
5243
  const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
5164
5244
  if (!localPath) continue;
@@ -5171,7 +5251,8 @@ const createCodexHandler = (config) => {
5171
5251
  url: repo.url,
5172
5252
  ref: repo.ref,
5173
5253
  targetPath,
5174
- sessionKey: chatId
5254
+ sessionKey: sessionCtx.chatId,
5255
+ agentName: branchAgentKey
5175
5256
  });
5176
5257
  ownedWorktrees.push({
5177
5258
  url: repo.url,
@@ -5413,7 +5494,8 @@ const createCodexHandler = (config) => {
5413
5494
  }
5414
5495
  });
5415
5496
  ensureFirstTreeBinding(cwd, sessionCtx);
5416
- await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
5497
+ await prepareGitWorktrees(payload, cwd, sessionCtx);
5498
+ markWorkspaceInitComplete(cwd);
5417
5499
  codex = new Codex({
5418
5500
  env: buildEnv(sessionCtx),
5419
5501
  config: buildCodexConfig(payload)
@@ -5437,7 +5519,7 @@ const createCodexHandler = (config) => {
5437
5519
  env: [],
5438
5520
  gitRepos: []
5439
5521
  };
5440
- if (!existsSync(join(cwd, ".agent", "identity.json"))) {
5522
+ if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) {
5441
5523
  bootstrapWorkspace({
5442
5524
  workspacePath: cwd,
5443
5525
  identity: sessionCtx.agent,
@@ -5451,7 +5533,8 @@ const createCodexHandler = (config) => {
5451
5533
  });
5452
5534
  ensureFirstTreeBinding(cwd, sessionCtx);
5453
5535
  }
5454
- await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
5536
+ await prepareGitWorktrees(payload, cwd, sessionCtx);
5537
+ markWorkspaceInitComplete(cwd);
5455
5538
  codex = new Codex({
5456
5539
  env: buildEnv(sessionCtx),
5457
5540
  config: buildCodexConfig(payload)
@@ -6111,11 +6194,24 @@ var SessionManager = class {
6111
6194
  this.persistRegistry();
6112
6195
  this.notifySessionState(chatId, "active");
6113
6196
  } catch (err) {
6197
+ const errMsg = err instanceof Error ? err.message : String(err);
6198
+ const phase = evicted ? "resume" : "start";
6114
6199
  this.config.log.error({
6115
6200
  chatId,
6116
6201
  err,
6117
- phase: evicted ? "resume" : "start"
6202
+ phase
6118
6203
  }, "session start/resume failed");
6204
+ this.notifySessionState(chatId, "errored");
6205
+ try {
6206
+ const preview = errMsg.slice(0, 800);
6207
+ const userMsg = `⚠️ Session ${phase} failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
6208
+ await ctx.forwardResult(userMsg);
6209
+ } catch (forwardErr) {
6210
+ this.config.log.warn({
6211
+ chatId,
6212
+ forwardErr
6213
+ }, "session error forward failed");
6214
+ }
6119
6215
  this.sessions.delete(chatId);
6120
6216
  this.sessionRuntimeStates.delete(chatId);
6121
6217
  this.recomputeRuntimeState();
@@ -6147,11 +6243,25 @@ var SessionManager = class {
6147
6243
  this.persistRegistry();
6148
6244
  this.notifySessionState(entry.chatId, "active");
6149
6245
  } catch (err) {
6150
- this.config.log.warn({
6246
+ const errMsg = err instanceof Error ? err.message : String(err);
6247
+ this.config.log.error({
6151
6248
  chatId: entry.chatId,
6152
6249
  err
6153
6250
  }, "resume failed");
6154
- entry.status = "suspended";
6251
+ this.notifySessionState(entry.chatId, "errored");
6252
+ try {
6253
+ const preview = errMsg.slice(0, 800);
6254
+ const userMsg = `⚠️ Session resume failed (${this.config.agentIdentity.displayName ?? this.config.agentIdentity.agentId}): ${preview}`;
6255
+ await ctx.forwardResult(userMsg);
6256
+ } catch (forwardErr) {
6257
+ this.config.log.warn({
6258
+ chatId: entry.chatId,
6259
+ forwardErr
6260
+ }, "session error forward failed");
6261
+ }
6262
+ this.sessions.delete(entry.chatId);
6263
+ this.sessionRuntimeStates.delete(entry.chatId);
6264
+ this.recomputeRuntimeState();
6155
6265
  this._activeCount--;
6156
6266
  }
6157
6267
  }
@@ -9326,7 +9436,7 @@ async function onboardCreate(args) {
9326
9436
  }
9327
9437
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9328
9438
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9329
- const { bindFeishuBot } = await import("./feishu-CsfadBKa.mjs").then((n) => n.r);
9439
+ const { bindFeishuBot } = await import("./feishu-DrnBbl8T.mjs").then((n) => n.r);
9330
9440
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9331
9441
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9332
9442
  else {
@@ -10539,7 +10649,7 @@ function createFeedbackHandler(config) {
10539
10649
  return { handle };
10540
10650
  }
10541
10651
  //#endregion
10542
- //#region ../server/dist/app-DFZ1LKZa.mjs
10652
+ //#region ../server/dist/app-mkBHfGPl.mjs
10543
10653
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10544
10654
  init_esm();
10545
10655
  var __defProp = Object.defineProperty;
@@ -10646,7 +10756,7 @@ async function requireChatAccess(request, db) {
10646
10756
  role: caller.role,
10647
10757
  humanAgentId: caller.humanAgentId
10648
10758
  };
10649
- const [direct] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, caller.humanAgentId))).limit(1);
10759
+ const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
10650
10760
  if (direct) {
10651
10761
  stampOrgScope(request, scope);
10652
10762
  stampChatResource(request, chat);
@@ -10655,7 +10765,7 @@ async function requireChatAccess(request, db) {
10655
10765
  scope
10656
10766
  };
10657
10767
  }
10658
- const participantRows = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
10768
+ const participantRows = await db.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
10659
10769
  if (participantRows.length === 0) throw new NotFoundError(`Chat "${chatId}" not found`);
10660
10770
  const participantIds = participantRows.map((p) => p.agentId);
10661
10771
  const [managed] = await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantIds), eq(agents.managerId, caller.memberId))).limit(1);
@@ -11747,19 +11857,6 @@ async function findOrCreateChatForChannel(db, data) {
11747
11857
  return chatId;
11748
11858
  });
11749
11859
  }
11750
- /**
11751
- * Ensure an agent is a participant of a chat (no-op if already). Mode is
11752
- * derived via the canonical entrypoint — pre-fix this also wrote `mode:`
11753
- * implicitly via schema default `'full'`, which is wrong for non-human
11754
- * agents in a group chat (the bug §1.1 of the Phase 1 design doc fixes).
11755
- */
11756
- async function ensureParticipant(db, chatId, agentId) {
11757
- const [exists] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
11758
- if (!exists) await addChatParticipants(db, chatId, [{
11759
- agentId,
11760
- role: "member"
11761
- }], { onConflictDoNothing: true });
11762
- }
11763
11860
  /** Store a cross-reference between internal and external message. */
11764
11861
  async function createMessageReference(db, data) {
11765
11862
  await db.insert(adapterMessageReferences).values({
@@ -11842,9 +11939,9 @@ async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
11842
11939
  const modeByChat = /* @__PURE__ */ new Map();
11843
11940
  if (chatIds.length > 0) {
11844
11941
  const rows = await db.select({
11845
- chatId: chatParticipants.chatId,
11846
- mode: chatParticipants.mode
11847
- }).from(chatParticipants).where(and(eq(chatParticipants.agentId, agentId), inArray(chatParticipants.chatId, chatIds)));
11942
+ chatId: chatMembership.chatId,
11943
+ mode: chatMembership.mode
11944
+ }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
11848
11945
  for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
11849
11946
  }
11850
11947
  const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
@@ -12245,7 +12342,7 @@ async function prepareImageOutbound(db, notifier, chatId, data) {
12245
12342
  * chat reply (see `services/message.ts` replyTo routing).
12246
12343
  */
12247
12344
  async function collectTargetInboxes(db, chatId, inReplyTo) {
12248
- const participants = await db.select({ inboxId: agents.inboxId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
12345
+ const participants = await db.select({ inboxId: agents.inboxId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
12249
12346
  const set = new Set(participants.map((p) => p.inboxId));
12250
12347
  if (inReplyTo) {
12251
12348
  const [original] = await db.select({ replyToInbox: messages.replyToInbox }).from(messages).where(eq(messages.id, inReplyTo)).limit(1);
@@ -15171,6 +15268,47 @@ async function bootstrapConfigRoutes(_app) {
15171
15268
  return { allowedOrg: null };
15172
15269
  });
15173
15270
  }
15271
+ /**
15272
+ * Per-(chat, agent) user state — independent from membership structure.
15273
+ *
15274
+ * This is the third layer of the chat data model: while `chats` owns
15275
+ * the entity and `chat_membership` owns the structural relation
15276
+ * (who can speak, who watches), this table owns the user's private
15277
+ * state about a chat. The reason it lives apart: structural changes
15278
+ * (speaker ↔ watcher, manager rebind, recompute) must never overwrite
15279
+ * user-private state — physical separation makes that an invariant
15280
+ * rather than a service-layer discipline.
15281
+ *
15282
+ * Columns evolve incrementally as new per-user state is needed.
15283
+ * Currently:
15284
+ * - `last_read_at`, `unread_mention_count` — seeded by PR-A from
15285
+ * the legacy `chat_participants` / `chat_subscriptions` columns.
15286
+ * - `engagement_status` — added in 0040; per-(chat, user) view
15287
+ * state (active / archived / deleted). Auto-revives archived →
15288
+ * active on new message; deleted is sticky (only the user can
15289
+ * restore from the chat detail page).
15290
+ *
15291
+ * Future fields slated for this table: pinned, mute_until, draft,
15292
+ * custom_title, last_seen_at — each as a separate change.
15293
+ *
15294
+ * Rows are lazy-upserted on first user write (markRead / mention
15295
+ * counter bump / engagement transition). Reads use COALESCE for
15296
+ * defaults so callers see `'active'` etc. even when no row exists.
15297
+ * Service-layer integrity (no FK / CHECK / trigger).
15298
+ *
15299
+ * See proposals/chat-data-model-restructure.20260512.md §8.6.
15300
+ */
15301
+ const chatUserState = pgTable("chat_user_state", {
15302
+ chatId: text("chat_id").notNull(),
15303
+ agentId: text("agent_id").notNull(),
15304
+ lastReadAt: timestamp("last_read_at", { withTimezone: true }),
15305
+ unreadMentionCount: integer("unread_mention_count").notNull().default(0),
15306
+ engagementStatus: text("engagement_status").notNull().default("active")
15307
+ }, (table) => [
15308
+ primaryKey({ columns: [table.chatId, table.agentId] }),
15309
+ index("idx_user_state_agent").on(table.agentId),
15310
+ index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
15311
+ ]);
15174
15312
  /** Extract a plain-text summary from a message's JSONB content field.
15175
15313
  * Used as the auto-title fallback in chat list rendering — see
15176
15314
  * `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
@@ -15366,7 +15504,7 @@ async function transitionSessionState(db, agentId, chatId, target, from, organiz
15366
15504
  async function filterSessionsByParticipant(db, sessions, participantAgentId) {
15367
15505
  if (sessions.length === 0) return [];
15368
15506
  const chatIds = sessions.map((s) => s.chatId);
15369
- const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
15507
+ const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
15370
15508
  const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
15371
15509
  return sessions.filter((s) => allowedChatIds.has(s.chatId));
15372
15510
  }
@@ -15375,16 +15513,17 @@ async function filterSessionsByParticipant(db, sessions, participantAgentId) {
15375
15513
  * workspace).
15376
15514
  *
15377
15515
  * Responsibilities:
15378
- * - Cursor-paginated conversation list across participant + watcher rows
15379
- * for the caller's human agent.
15516
+ * - Cursor-paginated conversation list (single-stream JOIN over the
15517
+ * unified `chat_membership` + `chat_user_state` tables).
15380
15518
  * - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
15381
- * - Add participants (idempotent, runs `recomputeChatWatchers` after).
15382
- * - Mark-read (touches whichever of the two tables holds the user's row).
15383
- * - Join → state-carry watcher → speaker (delegates to `watcher.ts`).
15384
- * - Leavestate-carry speaker → watcher (delegates to `watcher.ts`).
15519
+ * - Add participants (idempotent, UPSERT into `chat_membership`,
15520
+ * runs `recomputeChatWatchers` after).
15521
+ * - Mark-read (UPSERT into `chat_user_state`).
15522
+ * - Joinwatcher to speaker (delegates to `watcher.ts`).
15523
+ * - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
15385
15524
  *
15386
- * See docs/chat-first-workspace-product-design.md "API Contract" + "Data
15387
- * Model".
15525
+ * See proposals/chat-data-model-restructure.20260512.md §8 (schema)
15526
+ * and §11.1 (per-route mapping).
15388
15527
  */
15389
15528
  function encodeCursor(lastMessageAt, chatId) {
15390
15529
  const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
@@ -15408,17 +15547,61 @@ function decodeCursor(cursor) {
15408
15547
  return null;
15409
15548
  }
15410
15549
  }
15550
+ const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
15551
+ /**
15552
+ * SQL predicate for each engagement view tab. `deleted` is never a valid view
15553
+ * value — deleted rows are reachable only through `GET /chats/:chatId` + the
15554
+ * Restore banner on the chat detail page.
15555
+ */
15556
+ const ENGAGEMENT_VIEW_PREDICATE = {
15557
+ active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
15558
+ archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
15559
+ all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
15560
+ };
15561
+ /**
15562
+ * Write the caller's engagement state for this chat. UPSERT into
15563
+ * `chat_user_state` — the row may not yet exist (the user might not have
15564
+ * marked-read or been @-mentioned), so an INSERT with the engagement value
15565
+ * is the first write; subsequent transitions are UPDATEs.
15566
+ *
15567
+ * Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
15568
+ */
15569
+ async function setChatEngagement(db, chatId, agentId, status) {
15570
+ await db.insert(chatUserState).values({
15571
+ chatId,
15572
+ agentId,
15573
+ unreadMentionCount: 0,
15574
+ engagementStatus: status
15575
+ }).onConflictDoUpdate({
15576
+ target: [chatUserState.chatId, chatUserState.agentId],
15577
+ set: { engagementStatus: status }
15578
+ });
15579
+ }
15580
+ /**
15581
+ * Read the caller's engagement state. Returns `'active'` when no
15582
+ * `chat_user_state` row exists yet (lazy-materialised; matches the SQL
15583
+ * `COALESCE(..., 'active')` used elsewhere).
15584
+ */
15585
+ async function getCallerEngagement(db, chatId, agentId) {
15586
+ const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
15587
+ return row?.engagementStatus ?? ACTIVE;
15588
+ }
15411
15589
  /**
15412
15590
  * GET /me/chats — cursor-paginated conversation list.
15413
15591
  *
15414
15592
  * SQL strategy:
15415
- * - One query that UNIONs participant rows and subscription rows for the
15416
- * caller's human agent, joined to chats. The UNION+coalesce keeps both
15417
- * `unread_mention_count` and `membership_kind` per row.
15418
- * - Filter `parent_chat_id IS NULL` (threads are excluded in v1).
15593
+ * - Single-stream query: `chats JOIN chat_membership LEFT JOIN
15594
+ * chat_user_state`. The membership row carries access_mode
15595
+ * (speaker "participant" / watcher → "watching"); the user
15596
+ * state row supplies the unread counter (COALESCE 0 when
15597
+ * row is missing).
15598
+ * - Filter `parent_chat_id IS NULL` (threads excluded in v1).
15599
+ * - Filter `c.organization_id = ?` to defend against historical
15600
+ * cross-org pollution rows that may still reference the caller
15601
+ * (see fix/cross-org-direct-chat-pollution).
15419
15602
  * - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
15420
- * - Cursor narrows the result to rows STRICTLY before `(cursor.ts, cursor.id)`.
15421
- * - Followed by a small participant-list lookup for the page only.
15603
+ * - Cursor narrows the result to rows STRICTLY before the cursor.
15604
+ * - Followed by a participants-list lookup for the page only.
15422
15605
  */
15423
15606
  async function listMeChats(db, humanAgentId, organizationId, query) {
15424
15607
  const limit = query.limit;
@@ -15426,28 +15609,12 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15426
15609
  if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
15427
15610
  const filterUnreadOnly = query.filter === "unread";
15428
15611
  const filterWatchingOnly = query.filter === "watching";
15612
+ const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
15429
15613
  const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
15430
15614
  const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
15431
15615
  OR c.last_message_at < ${cursorTsIso}::timestamptz
15432
15616
  OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
15433
15617
  const rawRows = await db.execute(sql`
15434
- WITH membership AS (
15435
- SELECT chat_id, 'participant'::text AS membership_kind, unread_mention_count
15436
- FROM chat_participants
15437
- WHERE agent_id = ${humanAgentId}
15438
- UNION ALL
15439
- SELECT chat_id, 'watching'::text AS membership_kind, unread_mention_count
15440
- FROM chat_subscriptions
15441
- WHERE agent_id = ${humanAgentId}
15442
- ),
15443
- /* Resolve duplicates (should not happen post-invariant-1, but cheap) by
15444
- preferring the participant row. */
15445
- deduped AS (
15446
- SELECT DISTINCT ON (chat_id)
15447
- chat_id, membership_kind, unread_mention_count
15448
- FROM membership
15449
- ORDER BY chat_id, CASE WHEN membership_kind = 'participant' THEN 0 ELSE 1 END
15450
- )
15451
15618
  SELECT
15452
15619
  c.id AS chat_id,
15453
15620
  c.type AS type,
@@ -15455,20 +15622,26 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15455
15622
  c.parent_chat_id AS parent_chat_id,
15456
15623
  c.last_message_at AS last_message_at,
15457
15624
  c.last_message_preview AS last_message_preview,
15458
- (SELECT count(*) FROM chat_participants WHERE chat_id = c.id) AS participant_count,
15459
- d.membership_kind AS membership_kind,
15460
- d.unread_mention_count AS unread_mention_count
15625
+ (SELECT count(*) FROM chat_membership
15626
+ WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
15627
+ cm.access_mode AS access_mode,
15628
+ COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
15629
+ COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
15461
15630
  FROM chats c
15462
- JOIN deduped d ON d.chat_id = c.id
15631
+ JOIN chat_membership cm
15632
+ ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
15633
+ LEFT JOIN chat_user_state cus
15634
+ ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
15463
15635
  WHERE c.parent_chat_id IS NULL
15464
- /* Scope to the caller's org. Without this, cross-org dirty chats
15465
- whose chat_participants still reference the caller's human agent
15466
- (historical pollution — see fix/cross-org-direct-chat-pollution)
15467
- would leak into the list and 404 on click via requireChatAccess. */
15636
+ /* Scope to the caller's org. Without this, cross-org dirty
15637
+ chats whose chat_membership still references the caller's
15638
+ human agent (historical pollution — see
15639
+ fix/cross-org-direct-chat-pollution) would leak into the
15640
+ list and 404 on click via requireChatAccess. */
15468
15641
  AND c.organization_id = ${organizationId}
15469
- /* Filter: unread / watching */
15470
- AND (${!filterUnreadOnly}::bool OR d.unread_mention_count > 0)
15471
- AND (${!filterWatchingOnly}::bool OR d.membership_kind = 'watching')
15642
+ AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
15643
+ AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
15644
+ AND ${engagementPredicate}
15472
15645
  AND ${cursorPredicate}
15473
15646
  ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
15474
15647
  LIMIT ${limit + 1}
@@ -15487,11 +15660,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15487
15660
  };
15488
15661
  const chatIds = pageRaw.map((r) => r.chat_id);
15489
15662
  const participantRows = await db.select({
15490
- chatId: chatParticipants.chatId,
15491
- agentId: chatParticipants.agentId,
15663
+ chatId: chatMembership.chatId,
15664
+ agentId: chatMembership.agentId,
15492
15665
  displayName: agents.displayName,
15493
15666
  type: agents.type
15494
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(inArray(chatParticipants.chatId, chatIds));
15667
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
15495
15668
  const participantsByChat = /* @__PURE__ */ new Map();
15496
15669
  for (const p of participantRows) {
15497
15670
  const list = participantsByChat.get(p.chatId) ?? [];
@@ -15515,10 +15688,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15515
15688
  rows: pageRaw.map((r) => {
15516
15689
  const participants = participantsByChat.get(r.chat_id) ?? [];
15517
15690
  const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
15691
+ const isSpeaker = r.access_mode === "speaker";
15518
15692
  return {
15519
15693
  chatId: r.chat_id,
15520
15694
  type: r.type,
15521
- membershipKind: r.membership_kind,
15695
+ membershipKind: isSpeaker ? "participant" : "watching",
15522
15696
  title,
15523
15697
  topic: r.topic,
15524
15698
  participants,
@@ -15526,7 +15700,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15526
15700
  lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
15527
15701
  lastMessagePreview: r.last_message_preview,
15528
15702
  unreadMentionCount: r.unread_mention_count,
15529
- canReply: r.membership_kind === "participant"
15703
+ canReply: isSpeaker,
15704
+ engagementStatus: r.engagement_status
15530
15705
  };
15531
15706
  }),
15532
15707
  nextCursor
@@ -15538,11 +15713,6 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15538
15713
  * 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
15539
15714
  * 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
15540
15715
  * 3. Participant join (fallback when chat has no messages yet)
15541
- *
15542
- * The first-message fallback is the chat-first equivalent of how
15543
- * ChatGPT / Claude.ai name conversations from the user's opening
15544
- * prompt — gives same-agent multi-chats distinct identities and
15545
- * removes the "title duplicates participants chip row" anti-pattern.
15546
15716
  */
15547
15717
  function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
15548
15718
  if (topic && topic.length > 0) return topic;
@@ -15596,7 +15766,7 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
15596
15766
  }).from(chats).where(eq(chats.id, chatId)).limit(1);
15597
15767
  if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
15598
15768
  if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
15599
- const [callerRow] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, callerHumanAgentId))).limit(1);
15769
+ const [callerRow] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
15600
15770
  if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
15601
15771
  const found = await db.select({
15602
15772
  uuid: agents.uuid,
@@ -15610,45 +15780,36 @@ async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrgan
15610
15780
  const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
15611
15781
  if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
15612
15782
  await db.transaction(async (tx) => {
15613
- const existing = await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
15614
- const existingSet = new Set(existing.map((e) => e.agentId));
15615
- const toInsert = distinct.filter((id) => !existingSet.has(id));
15616
- if (toInsert.length === 0) {
15783
+ const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
15784
+ const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
15785
+ const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
15786
+ if (toUpsert.length === 0) {
15617
15787
  await recomputeChatWatchers(tx, chatId);
15618
15788
  return;
15619
15789
  }
15620
- if (existing.length + toInsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
15621
- const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
15622
- agentId: chatSubscriptions.agentId,
15623
- lastReadAt: chatSubscriptions.lastReadAt,
15624
- unreadMentionCount: chatSubscriptions.unreadMentionCount
15625
- });
15626
- const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
15627
- await addChatParticipants(tx, chatId, toInsert.map((agentId) => {
15628
- const carried = carriedByAgent.get(agentId);
15629
- return {
15630
- agentId,
15631
- role: "member",
15632
- carriedReadState: carried ? {
15633
- lastReadAt: carried.lastReadAt,
15634
- unreadMentionCount: carried.unreadMentionCount
15635
- } : void 0
15636
- };
15637
- }), { onConflictDoNothing: true });
15790
+ if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
15791
+ await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
15792
+ agentId,
15793
+ role: "member"
15794
+ })), { upgradeWatcherToSpeaker: true });
15638
15795
  await recomputeChatWatchers(tx, chatId);
15639
15796
  });
15640
15797
  invalidateChatAudience(chatId);
15641
15798
  }
15642
15799
  async function markMeChatRead(db, chatId, humanAgentId) {
15643
15800
  const now = /* @__PURE__ */ new Date();
15644
- await db.update(chatParticipants).set({
15645
- lastReadAt: now,
15646
- unreadMentionCount: 0
15647
- }).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId)));
15648
- await db.update(chatSubscriptions).set({
15801
+ await db.insert(chatUserState).values({
15802
+ chatId,
15803
+ agentId: humanAgentId,
15649
15804
  lastReadAt: now,
15650
15805
  unreadMentionCount: 0
15651
- }).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId)));
15806
+ }).onConflictDoUpdate({
15807
+ target: [chatUserState.chatId, chatUserState.agentId],
15808
+ set: {
15809
+ lastReadAt: now,
15810
+ unreadMentionCount: 0
15811
+ }
15812
+ });
15652
15813
  return {
15653
15814
  chatId,
15654
15815
  lastReadAt: now.toISOString(),
@@ -15674,7 +15835,7 @@ async function leaveMeChat(db, chatId, humanAgentId) {
15674
15835
  async function chatRoutes(app) {
15675
15836
  app.get("/:chatId", async (request) => {
15676
15837
  const { chat, scope } = await requireChatAccess(request, app.db);
15677
- const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chat.id));
15838
+ const participants = await app.db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
15678
15839
  const firstMsgRows = await app.db.execute(sql`
15679
15840
  SELECT content FROM messages
15680
15841
  WHERE chat_id = ${chat.id}
@@ -15698,10 +15859,12 @@ async function chatRoutes(app) {
15698
15859
  };
15699
15860
  });
15700
15861
  const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
15862
+ const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
15701
15863
  return {
15702
15864
  ...chat,
15703
15865
  title,
15704
15866
  firstMessagePreview,
15867
+ engagementStatus,
15705
15868
  createdAt: chat.createdAt.toISOString(),
15706
15869
  updatedAt: chat.updatedAt.toISOString(),
15707
15870
  participants: participants.map((p) => ({
@@ -15712,6 +15875,15 @@ async function chatRoutes(app) {
15712
15875
  }))
15713
15876
  };
15714
15877
  });
15878
+ app.post("/:chatId/engagement", { config: { otelRecordBody: true } }, async (request, reply) => {
15879
+ const { scope } = await requireChatAccess(request, app.db);
15880
+ const body = patchChatEngagementSchema.parse(request.body);
15881
+ await setChatEngagement(app.db, request.params.chatId, scope.humanAgentId, body.status);
15882
+ return reply.status(200).send({
15883
+ chatId: request.params.chatId,
15884
+ engagementStatus: body.status
15885
+ });
15886
+ });
15715
15887
  app.patch("/:chatId", { config: { otelRecordBody: true } }, async (request) => {
15716
15888
  await requireChatAccess(request, app.db);
15717
15889
  const body = updateChatSchema.parse(request.body);
@@ -15804,7 +15976,7 @@ async function chatRoutes(app) {
15804
15976
  app.post("/:chatId/messages", async (request, reply) => {
15805
15977
  const { scope } = await requireChatAccess(request, app.db);
15806
15978
  const body = sendMessageSchema.parse(request.body);
15807
- await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
15979
+ await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
15808
15980
  const prepared = await prepareImageOutbound(app.db, app.notifier, request.params.chatId, {
15809
15981
  ...body,
15810
15982
  source: "hub_ui"
@@ -15831,7 +16003,7 @@ async function chatRoutes(app) {
15831
16003
  app.post("/:chatId/questions/:correlationId/answer", { config: { otelRecordBody: false } }, async (request, reply) => {
15832
16004
  const { scope } = await requireChatAccess(request, app.db);
15833
16005
  const body = submitQuestionAnswerSchema.parse(request.body);
15834
- await ensureParticipant$1(app.db, request.params.chatId, scope.humanAgentId);
16006
+ await ensureParticipant(app.db, request.params.chatId, scope.humanAgentId);
15835
16007
  const result = await submitAnswer(app.db, app.notifier, {
15836
16008
  correlationId: request.params.correlationId,
15837
16009
  chatId: request.params.chatId,
@@ -17228,7 +17400,7 @@ async function healthzRoutes(app) {
17228
17400
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17229
17401
  */
17230
17402
  async function publicInvitationRoutes(app) {
17231
- const { previewInvitation } = await import("./invitation-C299fxkP-DFBBuUcj.mjs");
17403
+ const { previewInvitation } = await import("./invitation-C299fxkP-KKslbta2.mjs");
17232
17404
  app.get("/:token/preview", async (request, reply) => {
17233
17405
  if (!request.params.token) throw new UnauthorizedError("Token required");
17234
17406
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17494,7 +17666,7 @@ async function meRoutes(app) {
17494
17666
  */
17495
17667
  app.get("/me/pinned-agents", async (request) => {
17496
17668
  const { userId } = requireUser(request);
17497
- const { listMyPinnedAgents } = await import("./client-GOgUQxVe-Dqk9oZf9.mjs");
17669
+ const { listMyPinnedAgents } = await import("./client-DNEtPEBu-BtHkUya2.mjs");
17498
17670
  return listMyPinnedAgents(app.db, { userId });
17499
17671
  });
17500
17672
  /**
@@ -17680,7 +17852,8 @@ async function orgActivityRoutes(app) {
17680
17852
  activeSessions: a.activeSessions,
17681
17853
  totalSessions: a.totalSessions,
17682
17854
  runtimeUpdatedAt: a.runtimeUpdatedAt?.toISOString() ?? null,
17683
- type: "type" in a ? a.type : null
17855
+ type: "type" in a ? a.type : null,
17856
+ managedByMe: "managerId" in a ? a.managerId === scope.memberId : false
17684
17857
  }))
17685
17858
  };
17686
17859
  });
@@ -17903,7 +18076,7 @@ async function orgChatRoutes(app) {
17903
18076
  createdAt: chats.createdAt,
17904
18077
  updatedAt: chats.updatedAt,
17905
18078
  participantCount: sql`(
17906
- SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
18079
+ SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker'
17907
18080
  )`
17908
18081
  }).from(chats).where(and(...conditions)).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
17909
18082
  const hasMore = rows.length > query.limit;
@@ -19429,8 +19602,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
19429
19602
  agentPresence: () => agentPresence,
19430
19603
  agents: () => agents,
19431
19604
  authIdentities: () => authIdentities,
19432
- chatParticipants: () => chatParticipants,
19433
- chatSubscriptions: () => chatSubscriptions,
19605
+ chatMembership: () => chatMembership,
19606
+ chatUserState: () => chatUserState,
19434
19607
  chats: () => chats,
19435
19608
  clients: () => clients,
19436
19609
  githubAppInstallations: () => githubAppInstallations,