@corenel/harness 0.20.0 → 0.22.0

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.
Files changed (137) hide show
  1. package/dist/chat/engine.d.ts +53 -0
  2. package/dist/chat/engine.d.ts.map +1 -0
  3. package/dist/chat/engine.js +885 -0
  4. package/dist/chat/engine.js.map +1 -0
  5. package/dist/chat/events.d.ts +5 -0
  6. package/dist/chat/events.d.ts.map +1 -0
  7. package/dist/chat/events.js +100 -0
  8. package/dist/chat/events.js.map +1 -0
  9. package/dist/chat/index.d.ts +9 -0
  10. package/dist/chat/index.d.ts.map +1 -0
  11. package/dist/chat/index.js +9 -0
  12. package/dist/chat/index.js.map +1 -0
  13. package/dist/chat/liveFold.d.ts +10 -0
  14. package/dist/chat/liveFold.d.ts.map +1 -0
  15. package/dist/chat/liveFold.js +31 -0
  16. package/dist/chat/liveFold.js.map +1 -0
  17. package/dist/chat/messages.d.ts +20 -0
  18. package/dist/chat/messages.d.ts.map +1 -0
  19. package/dist/chat/messages.js +56 -0
  20. package/dist/chat/messages.js.map +1 -0
  21. package/dist/chat/persist.d.ts +47 -0
  22. package/dist/chat/persist.d.ts.map +1 -0
  23. package/dist/chat/persist.js +118 -0
  24. package/dist/chat/persist.js.map +1 -0
  25. package/dist/chat/runWatchdog.d.ts +21 -0
  26. package/dist/chat/runWatchdog.d.ts.map +1 -0
  27. package/dist/chat/runWatchdog.js +92 -0
  28. package/dist/chat/runWatchdog.js.map +1 -0
  29. package/dist/chat/stitch.d.ts +26 -0
  30. package/dist/chat/stitch.d.ts.map +1 -0
  31. package/dist/chat/stitch.js +40 -0
  32. package/dist/chat/stitch.js.map +1 -0
  33. package/dist/chat/types.d.ts +163 -0
  34. package/dist/chat/types.d.ts.map +1 -0
  35. package/dist/chat/types.js +2 -0
  36. package/dist/chat/types.js.map +1 -0
  37. package/dist/core/loop.d.ts.map +1 -1
  38. package/dist/core/loop.js +50 -15
  39. package/dist/core/loop.js.map +1 -1
  40. package/dist/core/spawn.d.ts +10 -0
  41. package/dist/core/spawn.d.ts.map +1 -1
  42. package/dist/core/spawn.js +28 -5
  43. package/dist/core/spawn.js.map +1 -1
  44. package/dist/fileSchema.js +1 -1
  45. package/dist/guardrail/builtins.d.ts.map +1 -1
  46. package/dist/guardrail/builtins.js +11 -6
  47. package/dist/guardrail/builtins.js.map +1 -1
  48. package/dist/guardrail/guardrail.d.ts +8 -0
  49. package/dist/guardrail/guardrail.d.ts.map +1 -1
  50. package/dist/guardrail/guardrail.js +12 -0
  51. package/dist/guardrail/guardrail.js.map +1 -1
  52. package/dist/guardrail/index.d.ts +1 -1
  53. package/dist/guardrail/index.d.ts.map +1 -1
  54. package/dist/guardrail/index.js +1 -1
  55. package/dist/guardrail/index.js.map +1 -1
  56. package/dist/guardrail/serialize.d.ts.map +1 -1
  57. package/dist/guardrail/serialize.js +3 -0
  58. package/dist/guardrail/serialize.js.map +1 -1
  59. package/dist/guardrail/validate.d.ts.map +1 -1
  60. package/dist/guardrail/validate.js +4 -0
  61. package/dist/guardrail/validate.js.map +1 -1
  62. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  63. package/dist/orchestrator/sidecarTools.js +8 -4
  64. package/dist/orchestrator/sidecarTools.js.map +1 -1
  65. package/dist/prompts/fitProject.d.ts +6 -0
  66. package/dist/prompts/fitProject.d.ts.map +1 -0
  67. package/dist/prompts/fitProject.js +6 -0
  68. package/dist/prompts/fitProject.js.map +1 -0
  69. package/dist/prompts/library/defaults.generated.js +9 -9
  70. package/dist/prompts/library/defaults.generated.js.map +1 -1
  71. package/dist/prompts/library/index.d.ts +2 -1
  72. package/dist/prompts/library/index.d.ts.map +1 -1
  73. package/dist/prompts/library/index.js +11 -16
  74. package/dist/prompts/library/index.js.map +1 -1
  75. package/dist/providers/gateway.d.ts +1 -0
  76. package/dist/providers/gateway.d.ts.map +1 -1
  77. package/dist/providers/gateway.js +1 -0
  78. package/dist/providers/gateway.js.map +1 -1
  79. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  80. package/dist/sessions/fileSessionStore.js.map +1 -1
  81. package/dist/sidecar/askUserBroker.d.ts +40 -0
  82. package/dist/sidecar/askUserBroker.d.ts.map +1 -0
  83. package/dist/sidecar/askUserBroker.js +103 -0
  84. package/dist/sidecar/askUserBroker.js.map +1 -0
  85. package/dist/sidecar/fleetJwe.d.ts +25 -0
  86. package/dist/sidecar/fleetJwe.d.ts.map +1 -0
  87. package/dist/sidecar/fleetJwe.js +115 -0
  88. package/dist/sidecar/fleetJwe.js.map +1 -0
  89. package/dist/sidecar/fsChanges.d.ts +23 -0
  90. package/dist/sidecar/fsChanges.d.ts.map +1 -0
  91. package/dist/sidecar/fsChanges.js +77 -0
  92. package/dist/sidecar/fsChanges.js.map +1 -0
  93. package/dist/sidecar/host.d.ts +91 -1
  94. package/dist/sidecar/host.d.ts.map +1 -1
  95. package/dist/sidecar/host.js +269 -6
  96. package/dist/sidecar/host.js.map +1 -1
  97. package/dist/sidecar/identity.d.ts +4 -0
  98. package/dist/sidecar/identity.d.ts.map +1 -1
  99. package/dist/sidecar/memberGate.d.ts.map +1 -1
  100. package/dist/sidecar/memberGate.js +5 -0
  101. package/dist/sidecar/memberGate.js.map +1 -1
  102. package/dist/sidecar/server.d.ts +61 -4
  103. package/dist/sidecar/server.d.ts.map +1 -1
  104. package/dist/sidecar/server.js +583 -13
  105. package/dist/sidecar/server.js.map +1 -1
  106. package/dist/systemParts.d.ts +0 -1
  107. package/dist/systemParts.d.ts.map +1 -1
  108. package/dist/systemParts.js +6 -2
  109. package/dist/systemParts.js.map +1 -1
  110. package/dist/todos.d.ts +8 -0
  111. package/dist/todos.d.ts.map +1 -1
  112. package/dist/todos.js +21 -17
  113. package/dist/todos.js.map +1 -1
  114. package/dist/tools/builtins.d.ts +14 -1
  115. package/dist/tools/builtins.d.ts.map +1 -1
  116. package/dist/tools/builtins.js +27 -3
  117. package/dist/tools/builtins.js.map +1 -1
  118. package/dist/tools/namespaces.d.ts +8 -0
  119. package/dist/tools/namespaces.d.ts.map +1 -1
  120. package/dist/tools/namespaces.js +18 -0
  121. package/dist/tools/namespaces.js.map +1 -1
  122. package/dist/tools/skillTool.d.ts.map +1 -1
  123. package/dist/tools/skillTool.js +22 -1
  124. package/dist/tools/skillTool.js.map +1 -1
  125. package/dist/tools/toolFacts.d.ts +9 -0
  126. package/dist/tools/toolFacts.d.ts.map +1 -0
  127. package/dist/tools/toolFacts.js +19 -0
  128. package/dist/tools/toolFacts.js.map +1 -0
  129. package/dist/tools/toolSearch.d.ts +25 -6
  130. package/dist/tools/toolSearch.d.ts.map +1 -1
  131. package/dist/tools/toolSearch.js +62 -14
  132. package/dist/tools/toolSearch.js.map +1 -1
  133. package/dist/tools/workflowTool.d.ts +34 -0
  134. package/dist/tools/workflowTool.d.ts.map +1 -0
  135. package/dist/tools/workflowTool.js +122 -0
  136. package/dist/tools/workflowTool.js.map +1 -0
  137. package/package.json +6 -3
@@ -1,9 +1,11 @@
1
- import { decodePcm16, FLEET_LOGINS_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
1
+ import { CHAT_FEATURE, CHAT_ID_RE, decodePcm16, FLEET_LOGINS_DENIED, SIGN_IN_PAUSED_DENIED, SPEECH_FEATURE, AGENT_CRED_ERROR_CODES, agentCredHeadersProblem, DEFAULT_STATE_DIR, PEER_DAEMON_FEATURE, TEAM_MEMBERS_FEATURE, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, AGENT_ACCESS_FEATURE, AGENT_RUNS_DEFAULT_LIMIT, AGENT_RUNS_FEATURE, AGENT_RUNS_MAX_LIMIT, PEER_LINKS_FEATURE, CHILD_EVENTS_FEATURE, FS_CHANGED_FEATURE, CREW_DEFINITION_FILES, crewConfigTarget, denialNote, isRunOrigin, cleanPerson } from '@corenel/protocol';
2
2
  import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, isLoopbackAddress, parseMcpToolAddress, isWebPushSubscription, MAX_ASSERTION_LEN, isClerkOrgId, MAX_WORKFLOW_PACKAGE_B64, MAX_WORKFLOW_REPLY_ERROR, WORKFLOW_RUNNER_FEATURE, } from '@corenel/protocol';
3
3
  import { activityForViewer, askForViewer, withoutViewerClaims } from './askView.js';
4
4
  import { capabilityMissingMessage } from './hostCapability.js';
5
5
  import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, bytesToB64url, codeProof, sameB64, B64URL_32, } from './deviceKeys.js';
6
6
  import { MEMBER_GATE_ALLOWED, pendingRefusal } from './memberGate.js';
7
+ import { AskUserBroker } from './askUserBroker.js';
8
+ import { fsChangesFor } from './fsChanges.js';
7
9
  /** A workflow reply's refusal, cut to the length a host accepts
8
10
  * (MAX_WORKFLOW_REPLY_ERROR): a longer one is refused whole by the host's
9
11
  * reply check, and the person would lose the reason. */
@@ -41,6 +43,9 @@ export const WATCH_REFUSED = 'You can watch only runs you started, or unattended
41
43
  export const ACCESS_NO_ROLE = 'NO_ACCESS: you have no role on this agent, or too low a role for this.';
42
44
  export const ACCESS_NOT_OWNER = "NOT_OWNER: only this agent's owner or the daemon admin can change it.";
43
45
  export const ACCESS_ADMIN_ONLY = 'NOT_OWNER: only the daemon admin can change the crew folder itself.';
46
+ /** A team agent's chats (`crew/<agent>/chats/**`) are written only by the
47
+ * daemon's chat store; no client may write or delete them over the config channel. */
48
+ export const ACCESS_CHAT_FILES = 'NOT_OWNER: chats are kept by the daemon and cannot be changed or deleted here.';
44
49
  export const ACCESS_STORE_REFUSED = 'The access store is never writable.';
45
50
  export const ACCESS_CHECK_FAILED = 'NOT_OWNER: could not check access.';
46
51
  export const ACCESS_FAILED = 'Access could not be checked or changed. The daemon log has the reason.';
@@ -95,6 +100,13 @@ function providedMcpUrls(v) {
95
100
  const PEER_DAEMON_MESSAGES = new Set([
96
101
  'hello', 'client-proof', 'identity-assert', 'host-call-result',
97
102
  ]);
103
+ /** The file tools whose success is news for the daemon's other clients (the
104
+ * `fs-changed` broadcast): every tool that writes or removes a file under a
105
+ * root. The fs tools have no rename or mkdir; a write makes its parents. */
106
+ const FS_CHANGE_TOOLS = new Set(['sidecar_fs_write_file', 'sidecar_fs_write_bytes', 'sidecar_fs_delete']);
107
+ /** Gives each connection the id it publishes file changes under, so the bus
108
+ * can tell the writer apart from everyone else. Per process, like the bus. */
109
+ let fsConnectionSeq = 0;
98
110
  /** Server names a credential message may never carry: auth.json is keyed by
99
111
  * server name, and these are Object.prototype's own. */
100
112
  const AGENT_CRED_RESERVED_SERVERS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -211,6 +223,146 @@ function agentRunsRefOf(x) {
211
223
  default: return null;
212
224
  }
213
225
  }
226
+ /* DAEMON CHAT INPUT (Harness Chat Plan 2). Validated here like the
227
+ * neighbouring messages, before the chat port sees any of it; the port checks
228
+ * ids again before a file is touched. */
229
+ /** A chat turn's text and one injected message: at most this many characters. */
230
+ const CHAT_MAX_TEXT = 200000;
231
+ const CHAT_MAX_AGENT_LEN = 128;
232
+ /** Messages in one run-inject. */
233
+ const CHAT_MAX_INJECT = 20;
234
+ const CHAT_MAX_RUN_ID_LEN = 200;
235
+ const CHAT_MAX_MESSAGE_ID_LEN = 128;
236
+ const CHAT_MAX_REASON_LEN = 500;
237
+ /** An ask_user answer: questions, choices per question, and their lengths. */
238
+ const CHAT_MAX_ANSWER_KEYS = 32;
239
+ const CHAT_MAX_ANSWER_CHOICES = 32;
240
+ const CHAT_MAX_ANSWER_LEN = 2000;
241
+ /** The ask-user deadline shown when the runner gave none: its default park
242
+ * timeout (PARK_TIMEOUT_MS in packages/sidecar, which this package cannot
243
+ * import). The runner's own bound still decides; this is only what is shown. */
244
+ const CHAT_ASK_FALLBACK_MS = 30 * 60 * 1000;
245
+ /** The runner's RUN_NO_ACCESS text: a chat needs a verified person. */
246
+ const CHAT_NO_ACCESS = 'You do not have access to run this agent.';
247
+ const CHAT_UNAVAILABLE = 'this sidecar does not hold chats';
248
+ const CHAT_NOT_ALONE = 'A chat turn runs on its own; it cannot take a session or a task.';
249
+ const CHAT_BAD_AGENT = 'That is not a crew agent name.';
250
+ const CHAT_BAD_TEXT = `A chat turn needs text of at most ${CHAT_MAX_TEXT} characters.`;
251
+ const CHAT_BAD_SPEC = 'chat must be an object.';
252
+ const CHAT_BAD_ID = 'CHAT_BAD_ID: that is not a chat id';
253
+ const CHAT_BAD_MEMBER = 'CHAT_BAD_ID: that is not a member id';
254
+ const CHAT_BAD_LIMIT = 'A chat limit must be a positive number of dollars.';
255
+ const CHAT_BAD_RUN = 'That is not a run id.';
256
+ const CHAT_BAD_INJECT = `Send 1 to ${CHAT_MAX_INJECT} messages, each with an id and text.`;
257
+ const CHAT_INJECT_FAILED = 'Could not deliver the messages.';
258
+ const CHAT_READ_FAILED = 'Could not read the chats.';
259
+ function chatAgentOk(a) {
260
+ return typeof a === 'string' && a.length <= CHAT_MAX_AGENT_LEN && AGENT_RUNS_NAME_RE.test(a);
261
+ }
262
+ function chatIdOk(x) {
263
+ return typeof x === 'string' && CHAT_ID_RE.test(x);
264
+ }
265
+ function plainObject(x) {
266
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
267
+ }
268
+ /** A chat message's `ref`: absent, or a valid ref naming the same agent.
269
+ * Null is a refusal. */
270
+ function chatRefOf(x, agent) {
271
+ if (x === undefined)
272
+ return {};
273
+ const ref = agentRunsRefOf(x);
274
+ return ref !== null && ref.agent === agent ? { ref } : null;
275
+ }
276
+ /** `run.chat` rebuilt from its own fields, or the refusal to send. */
277
+ function chatSpecOf(x, agent) {
278
+ if (!plainObject(x))
279
+ return CHAT_BAD_SPEC;
280
+ if (x.conversationId !== undefined && !chatIdOk(x.conversationId))
281
+ return CHAT_BAD_ID;
282
+ const maxUsd = x.maxUsd;
283
+ if (maxUsd !== undefined && !(typeof maxUsd === 'number' && Number.isFinite(maxUsd) && maxUsd > 0))
284
+ return CHAT_BAD_LIMIT;
285
+ const ref = chatRefOf(x.ref, agent);
286
+ if (ref === null)
287
+ return AGENT_RUNS_BAD_REF;
288
+ return {
289
+ conversationId: typeof x.conversationId === 'string' ? x.conversationId : null,
290
+ ...(typeof maxUsd === 'number' ? { maxUsd } : {}),
291
+ ...ref,
292
+ };
293
+ }
294
+ /** run-inject's messages rebuilt, or null when any is malformed. */
295
+ function injectMessagesOf(x) {
296
+ if (!Array.isArray(x) || x.length === 0 || x.length > CHAT_MAX_INJECT)
297
+ return null;
298
+ const out = [];
299
+ for (const m of x) {
300
+ if (!plainObject(m))
301
+ return null;
302
+ const { id, text } = m;
303
+ if (typeof id !== 'string' || !id || id.length > CHAT_MAX_MESSAGE_ID_LEN || hasControlChar(id))
304
+ return null;
305
+ if (typeof text !== 'string' || !text.trim() || text.length > CHAT_MAX_TEXT)
306
+ return null;
307
+ out.push({ id, text });
308
+ }
309
+ return out;
310
+ }
311
+ /** An ask-user answer rebuilt: null (declined), the answers, or undefined
312
+ * when it is malformed (then it is ignored, and the ask stays open). */
313
+ function askAnswersOf(x) {
314
+ if (x === null)
315
+ return null;
316
+ if (!plainObject(x))
317
+ return undefined;
318
+ const entries = Object.entries(x);
319
+ if (entries.length > CHAT_MAX_ANSWER_KEYS)
320
+ return undefined;
321
+ const out = [];
322
+ for (const [key, choices] of entries) {
323
+ if (key.length > CHAT_MAX_ANSWER_LEN || !Array.isArray(choices) || choices.length > CHAT_MAX_ANSWER_CHOICES)
324
+ return undefined;
325
+ const kept = [];
326
+ for (const c of choices) {
327
+ if (typeof c !== 'string' || c.length > CHAT_MAX_ANSWER_LEN)
328
+ return undefined;
329
+ kept.push(c);
330
+ }
331
+ out.push([key, kept]);
332
+ }
333
+ return Object.fromEntries(out);
334
+ }
335
+ /** A summary rebuilt from its own fields, so a port that returned more could
336
+ * never put it on the wire. */
337
+ function chatSummaryOf(c) {
338
+ return {
339
+ conversationId: String(c.conversationId), agent: String(c.agent), title: String(c.title),
340
+ updatedAt: Number(c.updatedAt), turns: Number(c.turns), usd: Number(c.usd),
341
+ };
342
+ }
343
+ function storedChatOf(c) {
344
+ return {
345
+ id: String(c.id), agent: String(c.agent), requester: String(c.requester), title: String(c.title),
346
+ messages: Array.isArray(c.messages) ? c.messages : [], updatedAt: Number(c.updatedAt),
347
+ ...(c.titleLocked !== undefined ? { titleLocked: c.titleLocked === true } : {}),
348
+ ...(typeof c.model === 'string' ? { model: c.model } : {}),
349
+ ...(typeof c.system === 'string' ? { system: c.system } : {}),
350
+ ...(typeof c.maxUsd === 'number' ? { maxUsd: c.maxUsd } : {}),
351
+ };
352
+ }
353
+ /** The text of a chat read failure a reply may carry: a coded refusal, or the
354
+ * runner's own wording about the agent. Anything else (a path, an errno) is
355
+ * null, and the caller logs it and sends fixed text. */
356
+ function chatErrorOf(err) {
357
+ const msg = errMessage(err);
358
+ if (msg.length > CHAT_MAX_REASON_LEN)
359
+ return null;
360
+ if (/^CHAT_[A-Z_]+: /.test(msg) || msg === CHAT_NO_ACCESS)
361
+ return msg;
362
+ if (/^(crew agent not found|invalid crew agent name): [A-Za-z0-9_-]*$/.test(msg))
363
+ return msg;
364
+ return null;
365
+ }
214
366
  /** The access code a thrown refusal carries, when it is one a reply may name. */
215
367
  function accessCodeOf(err) {
216
368
  const code = typeof err === 'object' && err !== null ? err.code : undefined;
@@ -635,6 +787,27 @@ export class SidecarServer {
635
787
  writable: true,
636
788
  value: void 0
637
789
  });
790
+ /** This connection's seat for chat ask_user (verified connections only),
791
+ * and its leave function, called once on close. */
792
+ Object.defineProperty(this, "askSeat", {
793
+ enumerable: true,
794
+ configurable: true,
795
+ writable: true,
796
+ value: void 0
797
+ });
798
+ Object.defineProperty(this, "askSeatLeave", {
799
+ enumerable: true,
800
+ configurable: true,
801
+ writable: true,
802
+ value: void 0
803
+ });
804
+ /** Used when the daemon wired no shared broker: this connection only. */
805
+ Object.defineProperty(this, "ownAskUsers", {
806
+ enumerable: true,
807
+ configurable: true,
808
+ writable: true,
809
+ value: void 0
810
+ });
638
811
  /** Detach from the peer routes, when this connection serves MCP. */
639
812
  Object.defineProperty(this, "peersDetach", {
640
813
  enumerable: true,
@@ -720,6 +893,14 @@ export class SidecarServer {
720
893
  writable: true,
721
894
  value: void 0
722
895
  });
896
+ /** What this connection publishes its file changes under on the shared bus,
897
+ * and how it recognises its own changes there (never sent back to it). */
898
+ Object.defineProperty(this, "fsConnectionId", {
899
+ enumerable: true,
900
+ configurable: true,
901
+ writable: true,
902
+ value: `c${++fsConnectionSeq}`
903
+ });
723
904
  /* ---- sessions ---- */
724
905
  /** Per-session stream stoppers for THIS connection.
725
906
  *
@@ -866,6 +1047,19 @@ export class SidecarServer {
866
1047
  return;
867
1048
  this.ch.send({ t: 'room-progress', room });
868
1049
  });
1050
+ /* Same gating as roots and rooms, plus: never the writer itself (it knows
1051
+ * what it wrote, and its own listeners already fired). Only the root and
1052
+ * the path cross, never content. No team rule narrows fs READS for a
1053
+ * member (onInvoke gates writes only, and every active member is served
1054
+ * the same file tools over the same roots), so any connection this would
1055
+ * reach could list the same path itself. */
1056
+ const offFsChanges = opts.fsChanges?.subscribe((c) => {
1057
+ if (c.by === this.fsConnectionId)
1058
+ return;
1059
+ if (!this.authed || this.closed || this.peerDaemon || this.memberPending())
1060
+ return;
1061
+ this.ch.send({ t: 'fs-changed', root: c.root, path: c.path });
1062
+ });
869
1063
  /* A member approved while connected is told so at once (Part E), so the
870
1064
  * waiting screen can move on; the gate itself re-reads the status on the
871
1065
  * next message. Only THIS connection's verified user is ever told.
@@ -934,6 +1128,9 @@ export class SidecarServer {
934
1128
  this.identityAssertResolve = undefined;
935
1129
  this.attendedDetach?.();
936
1130
  this.attendedDetach = undefined;
1131
+ this.askSeatLeave?.();
1132
+ this.askSeatLeave = undefined;
1133
+ this.askSeat = undefined;
937
1134
  this.peersDetach?.();
938
1135
  this.peersDetach = undefined;
939
1136
  // Leave the live set, or this directory reads as connected for ever and
@@ -947,6 +1144,7 @@ export class SidecarServer {
947
1144
  this.runDetachers.clear();
948
1145
  offRoots?.();
949
1146
  offRooms?.();
1147
+ offFsChanges?.();
950
1148
  offMembers?.();
951
1149
  offAdmission?.();
952
1150
  // Stop streaming process output into a dead socket. The PROCESSES keep
@@ -1065,8 +1263,47 @@ export class SidecarServer {
1065
1263
  this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
1066
1264
  return;
1067
1265
  }
1266
+ // A chat turn (Harness Chat Plan 2) takes its own path: the daemon's chat port, never runner.run.
1267
+ if (m.chat !== undefined) {
1268
+ this.onChatRun(m);
1269
+ return;
1270
+ }
1068
1271
  void this.onRun(m);
1069
1272
  return;
1273
+ case 'run-inject':
1274
+ if (!this.authed) {
1275
+ this.ch.send({ t: 'run-inject-result', id: m.id, ok: false, reason: 'not authenticated' });
1276
+ return;
1277
+ }
1278
+ void this.onRunInject(m);
1279
+ return;
1280
+ case 'chat-list':
1281
+ if (!this.authed) {
1282
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: 'not authenticated' });
1283
+ return;
1284
+ }
1285
+ void this.onChatList(m);
1286
+ return;
1287
+ case 'chat-get':
1288
+ if (!this.authed) {
1289
+ this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error: 'not authenticated' });
1290
+ return;
1291
+ }
1292
+ void this.onChatGet(m);
1293
+ return;
1294
+ case 'ask-user-answer': {
1295
+ // No reply channel, like permission-answer: before authentication, or
1296
+ // from a connection with no seat, it is dropped. The broker ignores an
1297
+ // answer from a seat the ask was not shown to, or for a settled id.
1298
+ if (!this.authed)
1299
+ return;
1300
+ const seat = this.askSeat;
1301
+ const answers = askAnswersOf(m.answers);
1302
+ if (!seat || !Number.isSafeInteger(m.id) || answers === undefined)
1303
+ return;
1304
+ this.askUsers().answer(m.id, seat, answers);
1305
+ return;
1306
+ }
1070
1307
  case 'subscribe':
1071
1308
  // Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
1072
1309
  // subscribe has no response channel, so no message is the correct
@@ -1623,6 +1860,24 @@ export class SidecarServer {
1623
1860
  this.noteOutcome('denied');
1624
1861
  this.countFailedHello();
1625
1862
  }
1863
+ /** Whether the policy admits nobody for now, for a reason that clears by itself. A throw reads as not paused. */
1864
+ paused() {
1865
+ try {
1866
+ return this.opts.admission?.paused?.() === true;
1867
+ }
1868
+ catch (err) {
1869
+ this.opts.log?.(`admission paused() threw: ${errMessage(err)}`);
1870
+ return false;
1871
+ }
1872
+ }
1873
+ /** Refuse a PROVEN device while sign-in is paused, saying so, and close: a retry is a fresh connection. Not a
1874
+ * failed credential, so it is not counted toward MAX_FAILED_HELLOS or against the address. */
1875
+ denyPaused() {
1876
+ this.opts.log?.('denied: sign-in is paused until the fleet state allows it again');
1877
+ this.ch.send({ t: 'denied', reason: SIGN_IN_PAUSED_DENIED });
1878
+ this.noteOutcome('paused');
1879
+ this.ch.close();
1880
+ }
1626
1881
  /** Tell the daemon's rate limiter how a hello ended. Its failure is never
1627
1882
  * the handshake's. */
1628
1883
  noteOutcome(outcome) {
@@ -1667,23 +1922,27 @@ export class SidecarServer {
1667
1922
  * caller's uniform denial on the wire. */
1668
1923
  async identityGate(jkt, boundUser) {
1669
1924
  if (!this.opts.identity)
1670
- return true;
1925
+ return 'ok';
1671
1926
  const sidecarId = this.opts.info?.id;
1672
1927
  if (!sidecarId) {
1673
1928
  this.opts.log?.('identity: refused (this daemon has no id for an assertion to name)');
1674
- return false;
1929
+ return 'refused';
1675
1930
  }
1676
1931
  const nonce = makeNonce();
1677
1932
  const orgId = this.opts.admission?.orgId;
1678
1933
  this.ch.send({ t: 'identity-challenge', nonce, sidecarId, ...(orgId ? { orgId } : {}), ...(this.challengeIssuer() ? { issuer: this.challengeIssuer() } : {}) });
1934
+ /* PAUSED: after the challenge (so an older client still reads an account refusal and keeps its device), and
1935
+ * without waiting for an assertion: nobody would be admitted whatever it said. */
1936
+ if (this.paused())
1937
+ return 'paused';
1679
1938
  const verdict = await this.awaitAndVerifyIdentity(sidecarId, nonce, jkt, IDENTITY_TIMEOUT_MS);
1680
1939
  if (!verdict.ok) {
1681
1940
  this.opts.log?.(`identity: unverified (${verdict.reason})`);
1682
- return false;
1941
+ return 'refused';
1683
1942
  }
1684
1943
  if (!this.admits(verdict.identity)) {
1685
1944
  this.opts.log?.(orgId ? `identity: verified, but not a member of ${orgId}` : 'identity: verified, but not the account this daemon belongs to');
1686
- return false;
1945
+ return this.paused() ? 'paused' : 'refused';
1687
1946
  }
1688
1947
  /* TEAM: ONE USER PER DEVICE. A device belongs to the first verified user
1689
1948
  * it connected as; another member signed in on the same browser does not
@@ -1691,7 +1950,7 @@ export class SidecarServer {
1691
1950
  * user). A device recorded with no user yet is taken by this one. */
1692
1951
  if (this.opts.admission?.oneUserPerDevice && boundUser !== undefined && boundUser !== verdict.identity.userId) {
1693
1952
  this.opts.log?.('identity: verified, but this device belongs to another user');
1694
- return false;
1953
+ return 'refused';
1695
1954
  }
1696
1955
  this.verifiedUserId = verdict.identity.userId;
1697
1956
  this.verifiedIdentity = verdict.identity;
@@ -1699,7 +1958,7 @@ export class SidecarServer {
1699
1958
  this.verifiedPerson = { ...(name ? { name } : {}), ...(email ? { email } : {}) };
1700
1959
  this.identityResult = { verified: true };
1701
1960
  this.opts.log?.('identity: verified');
1702
- return true;
1961
+ return 'ok';
1703
1962
  }
1704
1963
  /** Start the periodic re-check of an authenticated connection's identity
1705
1964
  * (`SidecarAdmission.recheckMs`, team mode). */
@@ -1839,9 +2098,13 @@ export class SidecarServer {
1839
2098
  return null;
1840
2099
  }
1841
2100
  const deviceJkt = await jwkThumbprint(key);
1842
- if (!(await this.identityGate(deviceJkt, record.verifiedUserId))) {
2101
+ const gate = await this.identityGate(deviceJkt, record.verifiedUserId);
2102
+ if (gate !== 'ok') {
1843
2103
  this.unwatchRevocation();
1844
- this.denyCredential('identity');
2104
+ if (gate === 'paused')
2105
+ this.denyPaused();
2106
+ else
2107
+ this.denyCredential('identity');
1845
2108
  return null;
1846
2109
  }
1847
2110
  return { deviceId: id, deviceJkt };
@@ -1896,8 +2159,12 @@ export class SidecarServer {
1896
2159
  }
1897
2160
  /* WHO, before anything is written: an unverified or unadmitted identity
1898
2161
  * is refused here with the code still unburned. */
1899
- if (!(await this.identityGate(keyJkt))) {
1900
- this.denyCredential('identity');
2162
+ const gate = await this.identityGate(keyJkt);
2163
+ if (gate !== 'ok') {
2164
+ if (gate === 'paused')
2165
+ this.denyPaused();
2166
+ else
2167
+ this.denyCredential('identity');
1901
2168
  return null;
1902
2169
  }
1903
2170
  const deviceId = makeDeviceId();
@@ -2019,7 +2286,10 @@ export class SidecarServer {
2019
2286
  if (this.verifiedIdentity && !this.admits(this.verifiedIdentity)) {
2020
2287
  this.opts.log?.('identity: no longer admitted by the fleet state');
2021
2288
  this.unwatchRevocation();
2022
- this.denyCredential('identity');
2289
+ if (this.paused())
2290
+ this.denyPaused();
2291
+ else
2292
+ this.denyCredential('identity');
2023
2293
  this.helloInFlight = false;
2024
2294
  return;
2025
2295
  }
@@ -2213,6 +2483,20 @@ export class SidecarServer {
2213
2483
  call: (method, args) => this.hostCall('mcp', method, args),
2214
2484
  });
2215
2485
  }
2486
+ /* Chat ask_user (R8): a verified person's connection takes a seat, so an
2487
+ * ask for them or for the admin reaches it, and one already open is
2488
+ * delivered now. A pending member is shown nothing, checked per send. */
2489
+ if (this.opts.runner?.chat && this.verifiedUserId && !isPeerDaemon) {
2490
+ const seat = {
2491
+ userId: this.verifiedUserId,
2492
+ ask: (p) => { if (!this.memberPending())
2493
+ this.ch.send({ t: 'ask-user', ...p }); },
2494
+ resolved: (id, by) => { if (!this.memberPending())
2495
+ this.ch.send({ t: 'ask-user-resolved', id, by }); },
2496
+ };
2497
+ this.askSeat = seat;
2498
+ this.askSeatLeave = this.askUsers().join(seat);
2499
+ }
2216
2500
  if (this.opts.attendance && !isPeerDaemon) {
2217
2501
  /* A pending member (Part E) is never a recipient of anything: every
2218
2502
  * send below checks the status at the moment it is made, so a member
@@ -2445,12 +2729,19 @@ export class SidecarServer {
2445
2729
  // A client starts a task only where the daemon will honour it: an older
2446
2730
  // runner ignores `task`, and a dropped task must never look like a start.
2447
2731
  ...(this.opts.runner?.supportsTasks ? [RUN_TASK_FEATURE] : []),
2732
+ // Daemon chat (Harness Chat Plan 2): run.chat, run-inject, chat-list,
2733
+ // chat-get and ask-user, only with a chat port behind them and a
2734
+ // verified user on this connection: every chat turn and read is refused
2735
+ // without one, so a client must not offer a surface that always refuses.
2736
+ ...(this.opts.runner?.chat && this.verifiedUserId ? [CHAT_FEATURE] : []),
2448
2737
  // Whether this daemon answers run-plan-get (a runner that reads run records).
2449
2738
  ...(this.opts.runner?.runPlan ? [RUN_PLAN_FEATURE] : []),
2450
2739
  // Whether this daemon fans a delegated callee's own node events out to
2451
2740
  // watchers of the parent run (the running-pills card). Only with a live
2452
2741
  // hub behind it -- there is otherwise nothing to fan out from.
2453
2742
  ...(this.opts.runs ? [CHILD_EVENTS_FEATURE] : []),
2743
+ // Whether a write another client makes reaches this one as fs-changed.
2744
+ ...(this.opts.fsChanges ? [FS_CHANGED_FEATURE] : []),
2454
2745
  ...(this.opts.acp ? ['acp'] : []),
2455
2746
  ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
2456
2747
  ...(this.opts.roots?.areas.length && this.opts.roots.create ? ['create-roots'] : []),
@@ -2906,6 +3197,9 @@ export class SidecarServer {
2906
3197
  }
2907
3198
  }
2908
3199
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
3200
+ // After the answer, and only for a change that happened.
3201
+ if (FS_CHANGE_TOOLS.has(m.name))
3202
+ this.publishFsChange(m.args);
2909
3203
  }
2910
3204
  catch (e) {
2911
3205
  // A read that finds nothing there (ENOENT) answered the question -- the
@@ -2917,6 +3211,31 @@ export class SidecarServer {
2917
3211
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
2918
3212
  }
2919
3213
  }
3214
+ /** Announce a file tool's change to the daemon's other clients. The root is
3215
+ * the one the tool resolved against, spelled as the daemon reports its
3216
+ * roots (that is what every client names it by), and the path is relative
3217
+ * to it, resolved as the tool confined it (fsChangesFor). Never throws:
3218
+ * the change already happened and its caller already has its answer. */
3219
+ publishFsChange(args) {
3220
+ const bus = this.opts.fsChanges;
3221
+ if (!bus)
3222
+ return;
3223
+ try {
3224
+ const path = typeof args.path === 'string' ? args.path : '';
3225
+ if (!path)
3226
+ return;
3227
+ const snap = this.opts.roots?.snapshot();
3228
+ const info = this.opts.info;
3229
+ const roots = snap?.roots ?? info?.roots ?? (info?.root ? [info.root] : []);
3230
+ const primary = snap?.root ?? info?.root ?? roots[0];
3231
+ // Where the tool actually wrote (a `../` or absolute path can land in another root).
3232
+ for (const change of fsChangesFor(roots, primary, args.root, path))
3233
+ bus.publish({ ...change, by: this.fsConnectionId });
3234
+ }
3235
+ catch (err) {
3236
+ this.opts.log?.(`fs change not announced: ${errMessage(err)}`);
3237
+ }
3238
+ }
2920
3239
  /** Runs a crew agent via the configured `runner` and streams its lifecycle back
2921
3240
  * to the host: `run-accepted` (with the daemon-assigned runId) once the runner
2922
3241
  * has started, one `event` per `onEvent` callback, then exactly one of
@@ -3267,6 +3586,225 @@ export class SidecarServer {
3267
3586
  this.ch.send({ t: 'run-error', id: m.id, error: message });
3268
3587
  }
3269
3588
  }
3589
+ /** The daemon's ask_user broker, or this connection's own when none is wired. */
3590
+ askUsers() {
3591
+ return this.opts.askUsers ?? (this.ownAskUsers ?? (this.ownAskUsers = new AskUserBroker()));
3592
+ }
3593
+ /** The sidecar admin's verified user id, now; undefined when unknown. A throw is "none". */
3594
+ chatAdmin() {
3595
+ try {
3596
+ return this.opts.access?.adminId();
3597
+ }
3598
+ catch {
3599
+ return undefined;
3600
+ }
3601
+ }
3602
+ /** One chat turn (Harness Chat Plan 2). The requester is THIS connection's
3603
+ * verified user and never a message field; with none it is refused. The
3604
+ * events, `run-accepted` (with the conversation id), `run-done` and the hub
3605
+ * publishing follow `onRun`. A refused or failed turn sends `run-error`. */
3606
+ onChatRun(m) {
3607
+ const refuse = (error) => { this.ch.send({ t: 'run-error', id: m.id, error }); };
3608
+ const port = this.opts.runner?.chat;
3609
+ if (!port) {
3610
+ refuse(CHAT_UNAVAILABLE);
3611
+ return;
3612
+ }
3613
+ if (m.sessionId !== undefined || m.task !== undefined || m.autonomy !== undefined) {
3614
+ refuse(CHAT_NOT_ALONE);
3615
+ return;
3616
+ }
3617
+ const user = this.verifiedUserId;
3618
+ if (!user) {
3619
+ refuse(CHAT_NO_ACCESS);
3620
+ return;
3621
+ }
3622
+ if (!chatAgentOk(m.agent)) {
3623
+ refuse(CHAT_BAD_AGENT);
3624
+ return;
3625
+ }
3626
+ if (typeof m.input !== 'string' || m.input.length > CHAT_MAX_TEXT) {
3627
+ refuse(CHAT_BAD_TEXT);
3628
+ return;
3629
+ }
3630
+ const spec = chatSpecOf(m.chat, m.agent);
3631
+ if (typeof spec === 'string') {
3632
+ refuse(spec);
3633
+ return;
3634
+ }
3635
+ const agent = m.agent;
3636
+ const hub = this.opts.runs;
3637
+ let runId;
3638
+ let ended = false;
3639
+ const early = [];
3640
+ const forward = (id, event) => {
3641
+ hub?.publish(id, event);
3642
+ this.ch.send({ t: 'event', id: m.id, runId: id, event });
3643
+ };
3644
+ const done = (r) => {
3645
+ if (ended)
3646
+ return;
3647
+ ended = true;
3648
+ if ((r.outcome === 'done' || r.outcome === 'stopped') && runId !== undefined) {
3649
+ const stopReason = r.stopReason ?? r.outcome;
3650
+ hub?.finish(runId, { text: r.text, stopReason });
3651
+ this.ch.send({ t: 'run-done', id: m.id, runId, text: r.text, stopReason, outcome: r.outcome });
3652
+ return;
3653
+ }
3654
+ const outcome = r.outcome === 'refused' ? 'refused' : 'failed';
3655
+ const error = r.error ?? (r.outcome === 'refused' ? 'The chat turn was refused.'
3656
+ : runId === undefined ? 'The chat turn ended without a run.' : 'The chat turn failed.');
3657
+ // Watchers too: a failed run must not look merely silent to a second viewer.
3658
+ if (runId !== undefined)
3659
+ hub?.fail(runId, error);
3660
+ this.ch.send({ t: 'run-error', id: m.id, error, outcome });
3661
+ };
3662
+ const askers = () => {
3663
+ const admin = this.chatAdmin();
3664
+ return admin && admin !== user ? [user, admin] : [user];
3665
+ };
3666
+ try {
3667
+ port.turn({
3668
+ agent, requester: user, identity: { clientId: this.clientId },
3669
+ conversationId: spec.conversationId, text: m.input,
3670
+ ...(spec.ref ? { ref: spec.ref } : {}),
3671
+ ...(spec.maxUsd !== undefined ? { maxUsd: spec.maxUsd } : {}),
3672
+ }, {
3673
+ onAccepted: (id, conversationId) => {
3674
+ // One run per turn: a second id is not this turn's.
3675
+ if (ended || runId !== undefined)
3676
+ return;
3677
+ runId = id;
3678
+ hub?.open(id, user);
3679
+ this.ch.send({ t: 'run-accepted', id: m.id, runId: id, conversationId });
3680
+ for (const e of early.splice(0))
3681
+ if (e.runId === id)
3682
+ forward(id, e.event);
3683
+ },
3684
+ onEvent: (id, event) => {
3685
+ if (ended)
3686
+ return;
3687
+ // Buffered until the run's id is known, as onRun does.
3688
+ if (runId === undefined) {
3689
+ early.push({ runId: id, event });
3690
+ return;
3691
+ }
3692
+ if (id === runId)
3693
+ forward(id, event);
3694
+ },
3695
+ onDone: done,
3696
+ // R8 / Q2: the requester and the admin, read when the question is asked.
3697
+ askUser: (id, questions, signal, deadline) => this.askUsers().ask({
3698
+ users: askers(), runId: id, agent, questions,
3699
+ deadline: typeof deadline === 'number' && Number.isFinite(deadline) ? deadline : this.now() + CHAT_ASK_FALLBACK_MS,
3700
+ }, signal),
3701
+ });
3702
+ }
3703
+ catch (err) {
3704
+ done({ conversationId: spec.conversationId, runId: null, outcome: 'failed', error: errMessage(err), text: '' });
3705
+ }
3706
+ }
3707
+ /** Messages typed while a chat turn runs. The requester is THIS
3708
+ * connection's verified user; the port refuses a run that is not theirs. */
3709
+ async onRunInject(m) {
3710
+ const reply = (ok, reason) => {
3711
+ this.ch.send({ t: 'run-inject-result', id: m.id, ok, ...(reason !== undefined ? { reason } : {}) });
3712
+ };
3713
+ const port = this.opts.runner?.chat;
3714
+ if (!port) {
3715
+ reply(false, CHAT_UNAVAILABLE);
3716
+ return;
3717
+ }
3718
+ const user = this.verifiedUserId;
3719
+ if (!user) {
3720
+ reply(false, CHAT_NO_ACCESS);
3721
+ return;
3722
+ }
3723
+ if (typeof m.runId !== 'string' || !m.runId || m.runId.length > CHAT_MAX_RUN_ID_LEN || hasControlChar(m.runId)) {
3724
+ reply(false, CHAT_BAD_RUN);
3725
+ return;
3726
+ }
3727
+ const messages = injectMessagesOf(m.messages);
3728
+ if (!messages) {
3729
+ reply(false, CHAT_BAD_INJECT);
3730
+ return;
3731
+ }
3732
+ try {
3733
+ const r = await port.inject(m.runId, user, messages, m.hard === true);
3734
+ if (r.ok === true)
3735
+ reply(true);
3736
+ else
3737
+ reply(false, typeof r.reason === 'string' && r.reason ? r.reason.slice(0, CHAT_MAX_REASON_LEN) : CHAT_INJECT_FAILED);
3738
+ }
3739
+ catch (err) {
3740
+ this.opts.log?.(`chat: run-inject threw: ${errMessage(err)}`);
3741
+ reply(false, CHAT_INJECT_FAILED);
3742
+ }
3743
+ }
3744
+ /** The checks chat-list and chat-get share: a port, a verified caller, and
3745
+ * a well-formed agent, ref and member. Answers the request's parts, or the
3746
+ * refusal to send. `isAdmin` is the daemon's own answer, never the message's. */
3747
+ chatReadOf(m) {
3748
+ const port = this.opts.runner?.chat;
3749
+ if (!port)
3750
+ return CHAT_UNAVAILABLE;
3751
+ const caller = this.verifiedUserId;
3752
+ if (!caller)
3753
+ return CHAT_NO_ACCESS;
3754
+ if (!chatAgentOk(m.agent))
3755
+ return CHAT_BAD_AGENT;
3756
+ const ref = chatRefOf(m.ref, m.agent);
3757
+ if (ref === null)
3758
+ return AGENT_RUNS_BAD_REF;
3759
+ if (m.member !== undefined && !chatIdOk(m.member))
3760
+ return CHAT_BAD_MEMBER;
3761
+ return {
3762
+ port, caller, agent: m.agent, ...ref,
3763
+ ...(m.member !== undefined ? { member: m.member } : {}),
3764
+ isAdmin: sameAdmin(caller, this.chatAdmin()),
3765
+ };
3766
+ }
3767
+ async onChatList(m) {
3768
+ const q = this.chatReadOf(m);
3769
+ if (typeof q === 'string') {
3770
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: q });
3771
+ return;
3772
+ }
3773
+ const { port, ...rest } = q;
3774
+ try {
3775
+ const chats = await port.list({ ...rest, identity: { clientId: this.clientId } });
3776
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: true, chats: chats.map(chatSummaryOf) });
3777
+ }
3778
+ catch (err) {
3779
+ const error = chatErrorOf(err);
3780
+ if (error === null)
3781
+ this.opts.log?.(`chat: chat-list threw: ${errMessage(err)}`);
3782
+ this.ch.send({ t: 'chat-list-result', id: m.id, ok: false, error: error ?? CHAT_READ_FAILED });
3783
+ }
3784
+ }
3785
+ async onChatGet(m) {
3786
+ const refuse = (error) => { this.ch.send({ t: 'chat-get-result', id: m.id, ok: false, error }); };
3787
+ const q = this.chatReadOf(m);
3788
+ if (typeof q === 'string') {
3789
+ refuse(q);
3790
+ return;
3791
+ }
3792
+ if (!chatIdOk(m.conversationId)) {
3793
+ refuse(CHAT_BAD_ID);
3794
+ return;
3795
+ }
3796
+ const { port, ...rest } = q;
3797
+ try {
3798
+ const conversation = await port.get({ ...rest, identity: { clientId: this.clientId }, conversationId: m.conversationId });
3799
+ this.ch.send({ t: 'chat-get-result', id: m.id, ok: true, conversation: storedChatOf(conversation) });
3800
+ }
3801
+ catch (err) {
3802
+ const error = chatErrorOf(err);
3803
+ if (error === null)
3804
+ this.opts.log?.(`chat: chat-get threw: ${errMessage(err)}`);
3805
+ refuse(error ?? CHAT_READ_FAILED);
3806
+ }
3807
+ }
3270
3808
  /** The identity to attribute a host-sync write to. Returns either the
3271
3809
  * authenticated `clientId`, or an error string explaining why there is none --
3272
3810
  * either this connection is not authenticated yet, or it authenticated with no
@@ -5100,6 +5638,21 @@ export class SidecarServer {
5100
5638
  const target = crewConfigTarget(canonical);
5101
5639
  if (!target)
5102
5640
  return {};
5641
+ /* A chat (R15/Q6) is its member's and the admin's alone, the agent's
5642
+ * owner included, and only the daemon writes it (Q1: chats are kept).
5643
+ * The chats folder may be listed with any role; the listing keeps only
5644
+ * this user's own requester folder (visibleConfigEntries). */
5645
+ const chat = target.kind === 'agent' ? chatContentOf(canonical) : null;
5646
+ if (target.kind === 'agent' && chat !== null) {
5647
+ if (!reading)
5648
+ return { refusal: ACCESS_CHAT_FILES };
5649
+ if (chat.requester === null) {
5650
+ return m.t === 'config-list' && (await access.can(daemonAgent(target.agent), user, 'see')) ? {} : { refusal };
5651
+ }
5652
+ if (sameAdmin(user, access.adminId()))
5653
+ return {};
5654
+ return user !== undefined && chat.requester === user ? {} : { refusal };
5655
+ }
5103
5656
  if (reading) {
5104
5657
  if (target.kind === 'crew')
5105
5658
  return {};
@@ -5151,7 +5704,9 @@ export class SidecarServer {
5151
5704
  * or null when that cannot be decided. Only whole agents are left out:
5152
5705
  * dropping individual entries would read as deletions to a syncing client.
5153
5706
  * The one exception is a run's session folder (`sessions/<runId>`), which
5154
- * is run content: only the runs this user may read are listed (D5). */
5707
+ * is run content: only the runs this user may read are listed (D5). So is a
5708
+ * chat's requester folder (`chats/<requester>`): only this user's own, or
5709
+ * every one for the admin, is listed (R15/Q6). */
5155
5710
  async visibleConfigEntries(svc, path, entries) {
5156
5711
  const access = this.teamAccess();
5157
5712
  if (!access)
@@ -5180,6 +5735,9 @@ export class SidecarServer {
5180
5735
  }
5181
5736
  if (!shown)
5182
5737
  continue;
5738
+ const chat = chatContentOf(full);
5739
+ if (chat !== null && chat.requester !== null && !isAdmin && (this.verifiedUserId === undefined || chat.requester !== this.verifiedUserId))
5740
+ continue;
5183
5741
  const run = runContentOf(full);
5184
5742
  if (run?.kind === 'sessions' && run.id !== null) {
5185
5743
  const key = `${target.agent}/${run.id}`;
@@ -5512,6 +6070,18 @@ function runContentOf(path) {
5512
6070
  const file = segs.length === 4 ? /^(.+)\.jsonl$/i.exec(segs[3]) : null;
5513
6071
  return { kind, id: file ? file[1] : null, folder: false };
5514
6072
  }
6073
+ /** Where a daemon-crew config path is inside an agent's chats
6074
+ * (`crew/<agent>/chats[/<requester>[/...]]`, the daemon chat store's layout).
6075
+ * `requester` is the folder's requester id (the `<requester>` segment, as the
6076
+ * canonical path spells it), null for the chats folder itself. Null for any
6077
+ * other path. The `chats` segment compares case-insensitively; the caller
6078
+ * hands in the canonical path. */
6079
+ function chatContentOf(path) {
6080
+ const segs = path.split('/');
6081
+ if (segs.length < 3 || segs[0].toLowerCase() !== 'crew' || segs[2].toLowerCase() !== 'chats')
6082
+ return null;
6083
+ return { requester: segs.length === 3 ? null : segs[3] || null };
6084
+ }
5515
6085
  /** `run-history`'s `crew`, off the wire: anything but the two known shapes is
5516
6086
  * treated as absent (the global crew, as an older client meant). The id is
5517
6087
  * only a selector among the CALLER's own roots -- the runner matches it