jinn-cli 0.7.8 → 0.9.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 (156) hide show
  1. package/dist/bin/jimmy.js +2 -1
  2. package/dist/bin/jimmy.js.map +1 -1
  3. package/dist/package.json +54 -0
  4. package/dist/src/cli/__tests__/migrate.test.d.ts +2 -0
  5. package/dist/src/cli/__tests__/migrate.test.d.ts.map +1 -0
  6. package/dist/src/cli/__tests__/migrate.test.js +79 -0
  7. package/dist/src/cli/__tests__/migrate.test.js.map +1 -0
  8. package/dist/src/cli/migrate.d.ts.map +1 -1
  9. package/dist/src/cli/migrate.js +3 -2
  10. package/dist/src/cli/migrate.js.map +1 -1
  11. package/dist/src/connectors/discord/index.d.ts +6 -0
  12. package/dist/src/connectors/discord/index.d.ts.map +1 -1
  13. package/dist/src/connectors/discord/index.js +9 -3
  14. package/dist/src/connectors/discord/index.js.map +1 -1
  15. package/dist/src/connectors/discord/threads.d.ts +1 -1
  16. package/dist/src/connectors/discord/threads.d.ts.map +1 -1
  17. package/dist/src/connectors/discord/threads.js +4 -4
  18. package/dist/src/connectors/discord/threads.js.map +1 -1
  19. package/dist/src/connectors/telegram/__tests__/connector.test.d.ts +2 -0
  20. package/dist/src/connectors/telegram/__tests__/connector.test.d.ts.map +1 -0
  21. package/dist/src/connectors/telegram/__tests__/connector.test.js +257 -0
  22. package/dist/src/connectors/telegram/__tests__/connector.test.js.map +1 -0
  23. package/dist/src/connectors/telegram/__tests__/format.test.d.ts +2 -0
  24. package/dist/src/connectors/telegram/__tests__/format.test.d.ts.map +1 -0
  25. package/dist/src/connectors/telegram/__tests__/format.test.js +79 -0
  26. package/dist/src/connectors/telegram/__tests__/format.test.js.map +1 -0
  27. package/dist/src/connectors/telegram/__tests__/threads.test.d.ts +2 -0
  28. package/dist/src/connectors/telegram/__tests__/threads.test.d.ts.map +1 -0
  29. package/dist/src/connectors/telegram/__tests__/threads.test.js +51 -0
  30. package/dist/src/connectors/telegram/__tests__/threads.test.js.map +1 -0
  31. package/dist/src/connectors/telegram/format.d.ts +12 -0
  32. package/dist/src/connectors/telegram/format.d.ts.map +1 -0
  33. package/dist/src/connectors/telegram/format.js +61 -0
  34. package/dist/src/connectors/telegram/format.js.map +1 -0
  35. package/dist/src/connectors/telegram/index.d.ts +26 -0
  36. package/dist/src/connectors/telegram/index.d.ts.map +1 -0
  37. package/dist/src/connectors/telegram/index.js +185 -0
  38. package/dist/src/connectors/telegram/index.js.map +1 -0
  39. package/dist/src/connectors/telegram/threads.d.ts +29 -0
  40. package/dist/src/connectors/telegram/threads.d.ts.map +1 -0
  41. package/dist/src/connectors/telegram/threads.js +26 -0
  42. package/dist/src/connectors/telegram/threads.js.map +1 -0
  43. package/dist/src/cron/runner.js.map +1 -1
  44. package/dist/src/engines/__tests__/gemini.test.d.ts +2 -0
  45. package/dist/src/engines/__tests__/gemini.test.d.ts.map +1 -0
  46. package/dist/src/engines/__tests__/gemini.test.js +404 -0
  47. package/dist/src/engines/__tests__/gemini.test.js.map +1 -0
  48. package/dist/src/engines/gemini.d.ts +32 -0
  49. package/dist/src/engines/gemini.d.ts.map +1 -0
  50. package/dist/src/engines/gemini.js +328 -0
  51. package/dist/src/engines/gemini.js.map +1 -0
  52. package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts +2 -0
  53. package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts.map +1 -0
  54. package/dist/src/gateway/__tests__/org-hierarchy.test.js +161 -0
  55. package/dist/src/gateway/__tests__/org-hierarchy.test.js.map +1 -0
  56. package/dist/src/gateway/__tests__/org-update.test.d.ts +2 -0
  57. package/dist/src/gateway/__tests__/org-update.test.d.ts.map +1 -0
  58. package/dist/src/gateway/__tests__/org-update.test.js +99 -0
  59. package/dist/src/gateway/__tests__/org-update.test.js.map +1 -0
  60. package/dist/src/gateway/__tests__/org.test.d.ts +2 -0
  61. package/dist/src/gateway/__tests__/org.test.d.ts.map +1 -0
  62. package/dist/src/gateway/__tests__/org.test.js +77 -0
  63. package/dist/src/gateway/__tests__/org.test.js.map +1 -0
  64. package/dist/src/gateway/__tests__/services.test.d.ts +2 -0
  65. package/dist/src/gateway/__tests__/services.test.d.ts.map +1 -0
  66. package/dist/src/gateway/__tests__/services.test.js +153 -0
  67. package/dist/src/gateway/__tests__/services.test.js.map +1 -0
  68. package/dist/src/gateway/api.d.ts +5 -0
  69. package/dist/src/gateway/api.d.ts.map +1 -1
  70. package/dist/src/gateway/api.js +302 -77
  71. package/dist/src/gateway/api.js.map +1 -1
  72. package/dist/src/gateway/org-hierarchy.d.ts +5 -0
  73. package/dist/src/gateway/org-hierarchy.d.ts.map +1 -0
  74. package/dist/src/gateway/org-hierarchy.js +174 -0
  75. package/dist/src/gateway/org-hierarchy.js.map +1 -0
  76. package/dist/src/gateway/org.d.ts +7 -0
  77. package/dist/src/gateway/org.d.ts.map +1 -1
  78. package/dist/src/gateway/org.js +64 -0
  79. package/dist/src/gateway/org.js.map +1 -1
  80. package/dist/src/gateway/server.d.ts.map +1 -1
  81. package/dist/src/gateway/server.js +262 -5
  82. package/dist/src/gateway/server.js.map +1 -1
  83. package/dist/src/gateway/services.d.ts +28 -0
  84. package/dist/src/gateway/services.d.ts.map +1 -0
  85. package/dist/src/gateway/services.js +112 -0
  86. package/dist/src/gateway/services.js.map +1 -0
  87. package/dist/src/sessions/__tests__/callbacks.test.js +36 -0
  88. package/dist/src/sessions/__tests__/callbacks.test.js.map +1 -1
  89. package/dist/src/sessions/callbacks.d.ts +2 -0
  90. package/dist/src/sessions/callbacks.d.ts.map +1 -1
  91. package/dist/src/sessions/callbacks.js +3 -1
  92. package/dist/src/sessions/callbacks.js.map +1 -1
  93. package/dist/src/sessions/context.d.ts +1 -0
  94. package/dist/src/sessions/context.d.ts.map +1 -1
  95. package/dist/src/sessions/context.js +121 -6
  96. package/dist/src/sessions/context.js.map +1 -1
  97. package/dist/src/sessions/fork.d.ts +30 -0
  98. package/dist/src/sessions/fork.d.ts.map +1 -0
  99. package/dist/src/sessions/fork.js +178 -0
  100. package/dist/src/sessions/fork.js.map +1 -0
  101. package/dist/src/sessions/manager.d.ts.map +1 -1
  102. package/dist/src/sessions/manager.js +16 -5
  103. package/dist/src/sessions/manager.js.map +1 -1
  104. package/dist/src/sessions/registry.d.ts +9 -0
  105. package/dist/src/sessions/registry.d.ts.map +1 -1
  106. package/dist/src/sessions/registry.js +40 -0
  107. package/dist/src/sessions/registry.js.map +1 -1
  108. package/dist/src/shared/types.d.ts +81 -1
  109. package/dist/src/shared/types.d.ts.map +1 -1
  110. package/dist/web/404.html +1 -1
  111. package/dist/web/_next/static/chunks/155-655a087df45da990.js +1 -0
  112. package/dist/web/_next/static/chunks/192-c0150e3d227be735.js +1 -0
  113. package/dist/web/_next/static/chunks/579-09eb1d7ab35333fc.js +1 -0
  114. package/dist/web/_next/static/chunks/943.7dec3c35287ad545.js +1 -0
  115. package/dist/web/_next/static/chunks/app/chat/page-4ba0008df3b5d032.js +1 -0
  116. package/dist/web/_next/static/chunks/app/cron/page-648f892629b4af65.js +1 -0
  117. package/dist/web/_next/static/chunks/app/kanban/{page-0aa74ece5109e58f.js → page-5e00ac0363936a53.js} +1 -1
  118. package/dist/web/_next/static/chunks/app/{layout-497d1e93737edeae.js → layout-f5c5165308a903e2.js} +1 -1
  119. package/dist/web/_next/static/chunks/app/org/page-0f7617805f6f6eb7.js +1 -0
  120. package/dist/web/_next/static/chunks/app/settings/page-5588e1b2bb8a5196.js +1 -0
  121. package/dist/web/_next/static/chunks/{webpack-2e375360ad2078fe.js → webpack-21588284a59cb80f.js} +1 -1
  122. package/dist/web/_next/static/css/334114aa3839160f.css +1 -0
  123. package/dist/web/chat.html +1 -1
  124. package/dist/web/chat.txt +5 -5
  125. package/dist/web/cron.html +1 -1
  126. package/dist/web/cron.txt +5 -5
  127. package/dist/web/index.html +1 -1
  128. package/dist/web/index.txt +5 -5
  129. package/dist/web/kanban.html +1 -1
  130. package/dist/web/kanban.txt +5 -5
  131. package/dist/web/logs.html +2 -2
  132. package/dist/web/logs.txt +5 -5
  133. package/dist/web/org.html +1 -1
  134. package/dist/web/org.txt +5 -5
  135. package/dist/web/sessions.html +1 -1
  136. package/dist/web/sessions.txt +4 -4
  137. package/dist/web/settings.html +1 -1
  138. package/dist/web/settings.txt +5 -5
  139. package/dist/web/skills.html +1 -1
  140. package/dist/web/skills.txt +5 -5
  141. package/package.json +5 -3
  142. package/template/AGENTS.md +132 -31
  143. package/template/CLAUDE.md +186 -24
  144. package/template/migrations/0.8.0/MIGRATION.md +73 -0
  145. package/template/skills/management/SKILL.md +32 -9
  146. package/dist/web/_next/static/chunks/155-5843f8840f40f6f8.js +0 -1
  147. package/dist/web/_next/static/chunks/192-f566317c5713c743.js +0 -1
  148. package/dist/web/_next/static/chunks/660-7b281827cf68988a.js +0 -1
  149. package/dist/web/_next/static/chunks/943.c30215b2dbec402b.js +0 -1
  150. package/dist/web/_next/static/chunks/app/chat/page-01d396112fbc8623.js +0 -1
  151. package/dist/web/_next/static/chunks/app/cron/page-9787c557594dc688.js +0 -1
  152. package/dist/web/_next/static/chunks/app/org/page-b14614ed22c7a80d.js +0 -1
  153. package/dist/web/_next/static/chunks/app/settings/page-c2b014fb0706aa88.js +0 -1
  154. package/dist/web/_next/static/css/29fd6f6f1915cce0.css +0 -1
  155. /package/dist/web/_next/static/{73aF2gsMYJ-t64sWQw1Av → VBCQXj1bVyMDiXQTiHQ-p}/_buildManifest.js +0 -0
  156. /package/dist/web/_next/static/{73aF2gsMYJ-t64sWQw1Av → VBCQXj1bVyMDiXQTiHQ-p}/_ssgManifest.js +0 -0
@@ -5,7 +5,8 @@ import path from "node:path";
5
5
  import yaml from "js-yaml";
6
6
  import { isInterruptibleEngine } from "../shared/types.js";
7
7
  import { buildContext } from "../sessions/context.js";
8
- import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
8
+ import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
9
+ import { forkEngineSession } from "../sessions/fork.js";
9
10
  import { CONFIG_PATH, CRON_RUNS, ORG_DIR, SKILLS_DIR, LOGS_DIR, TMP_DIR, FILES_DIR, } from "../shared/paths.js";
10
11
  import { logger } from "../shared/logger.js";
11
12
  import { getSttStatus, downloadModel, transcribe as sttTranscribe, resolveLanguages, WHISPER_LANGUAGES } from "../stt/stt.js";
@@ -185,12 +186,34 @@ function badRequest(res, message) {
185
186
  function serverError(res, message) {
186
187
  json(res, { error: message }, 500);
187
188
  }
189
+ const SANITIZED_KEYS = new Set(["token", "botToken", "signingSecret", "appToken"]);
188
190
  function deepMerge(target, source) {
189
191
  const result = { ...target };
190
192
  for (const key of Object.keys(source)) {
191
193
  const sv = source[key];
192
194
  const tv = target[key];
193
- if (sv && typeof sv === "object" && !Array.isArray(sv) && tv && typeof tv === "object" && !Array.isArray(tv)) {
195
+ // Skip sanitized secret placeholders keep original value
196
+ if (SANITIZED_KEYS.has(key) && sv === "***")
197
+ continue;
198
+ if (Array.isArray(sv)) {
199
+ // For arrays (e.g. instances), preserve secrets from matching items
200
+ if (Array.isArray(tv)) {
201
+ result[key] = sv.map((item) => {
202
+ if (item && typeof item === "object" && !Array.isArray(item)) {
203
+ const srcItem = item;
204
+ // Find matching target item by id
205
+ const matchTarget = tv.find((t) => t && typeof t === "object" && t.id === srcItem.id);
206
+ if (matchTarget)
207
+ return deepMerge(matchTarget, srcItem);
208
+ }
209
+ return item;
210
+ });
211
+ }
212
+ else {
213
+ result[key] = sv;
214
+ }
215
+ }
216
+ else if (sv && typeof sv === "object" && !Array.isArray(sv) && tv && typeof tv === "object" && !Array.isArray(tv)) {
194
217
  result[key] = deepMerge(tv, sv);
195
218
  }
196
219
  else {
@@ -255,6 +278,7 @@ export async function handleApiRequest(req, res, context) {
255
278
  default: config.engines.default,
256
279
  claude: { model: config.engines.claude.model, available: true },
257
280
  codex: { model: config.engines.codex.model, available: true },
281
+ ...(config.engines.gemini ? { gemini: { model: config.engines.gemini.model, available: true } } : {}),
258
282
  },
259
283
  sessions: { total: sessions.length, running, active: running },
260
284
  connectors,
@@ -307,6 +331,34 @@ export async function handleApiRequest(req, res, context) {
307
331
  }
308
332
  return json(res, { ...serializeSession(session, context), messages });
309
333
  }
334
+ // PUT /api/sessions/:id
335
+ params = matchRoute("/api/sessions/:id", pathname);
336
+ if (method === "PUT" && params) {
337
+ const session = getSession(params.id);
338
+ if (!session)
339
+ return notFound(res);
340
+ const _parsed = await readJsonBody(req, res);
341
+ if (!_parsed.ok)
342
+ return;
343
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
+ const body = _parsed.body;
345
+ const updates = {};
346
+ if (body.title !== undefined) {
347
+ if (typeof body.title !== "string")
348
+ return badRequest(res, "title must be a string");
349
+ const trimmed = body.title.trim();
350
+ if (!trimmed)
351
+ return badRequest(res, "title must not be empty");
352
+ updates.title = trimmed.slice(0, 200);
353
+ }
354
+ if (Object.keys(updates).length === 0)
355
+ return badRequest(res, "no valid fields to update");
356
+ const updated = updateSession(params.id, updates);
357
+ if (!updated)
358
+ return notFound(res);
359
+ context.emit("session:updated", { sessionId: params.id });
360
+ return json(res, serializeSession(updated, context));
361
+ }
310
362
  // DELETE /api/sessions/:id
311
363
  params = matchRoute("/api/sessions/:id", pathname);
312
364
  if (method === "DELETE" && params) {
@@ -366,6 +418,45 @@ export async function handleApiRequest(req, res, context) {
366
418
  context.emit("session:updated", { sessionId: params.id });
367
419
  return json(res, { status: "reset", sessionId: params.id });
368
420
  }
421
+ // POST /api/sessions/:id/duplicate — duplicate a session (snapshot fork)
422
+ params = matchRoute("/api/sessions/:id/duplicate", pathname);
423
+ if (method === "POST" && params) {
424
+ const source = getSession(params.id);
425
+ if (!source)
426
+ return notFound(res);
427
+ if (!source.engineSessionId) {
428
+ res.writeHead(400, { "Content-Type": "application/json" });
429
+ res.end(JSON.stringify({ error: "Session has no engine session ID — cannot duplicate" }));
430
+ return;
431
+ }
432
+ let newSessionId = null;
433
+ try {
434
+ // 1. Duplicate session + messages in the registry
435
+ const { session: newSession, messageCount } = duplicateSession(params.id);
436
+ newSessionId = newSession.id;
437
+ // 2. Fork the engine session (Claude/Codex/Gemini)
438
+ const forkResult = forkEngineSession(source.engine, source.engineSessionId, JINN_HOME);
439
+ // 3. Store the new engine session ID
440
+ updateSession(newSession.id, { engineSessionId: forkResult.engineSessionId });
441
+ const result = getSession(newSession.id);
442
+ logger.info(`Session duplicated: ${params.id} → ${newSession.id} (engine: ${forkResult.engineSessionId}, ${messageCount} messages)`);
443
+ context.emit("session:created", { sessionId: newSession.id });
444
+ return json(res, serializeSession(result, context));
445
+ }
446
+ catch (err) {
447
+ // Clean up orphaned session if the engine fork failed after DB insert
448
+ if (newSessionId) {
449
+ try {
450
+ deleteSession(newSessionId);
451
+ }
452
+ catch { /* best effort */ }
453
+ }
454
+ logger.error(`Failed to duplicate session ${params.id}: ${err.message}`);
455
+ res.writeHead(500, { "Content-Type": "application/json" });
456
+ res.end(JSON.stringify({ error: `Duplicate failed: ${err.message}` }));
457
+ return;
458
+ }
459
+ }
369
460
  // DELETE /api/sessions/:id/queue/:itemId — cancel specific item
370
461
  const queueItemParams = matchRoute("/api/sessions/:id/queue/:itemId", pathname);
371
462
  if (method === "DELETE" && queueItemParams) {
@@ -728,62 +819,153 @@ export async function handleApiRequest(req, res, context) {
728
819
  // GET /api/org
729
820
  if (method === "GET" && pathname === "/api/org") {
730
821
  if (!fs.existsSync(ORG_DIR))
731
- return json(res, { departments: [], employees: [] });
822
+ return json(res, { departments: [], employees: [], hierarchy: { root: null, sorted: [], warnings: [] } });
732
823
  const entries = fs.readdirSync(ORG_DIR, { withFileTypes: true });
733
824
  const departments = entries
734
825
  .filter((e) => e.isDirectory())
735
826
  .map((e) => e.name);
736
- const employees = [];
737
- // Scan root-level YAML files
738
- for (const e of entries) {
739
- if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
740
- employees.push(e.name.replace(/\.ya?ml$/, ""));
741
- }
742
- }
743
- // Scan employees/ subdirectory
744
- const employeesDir = path.join(ORG_DIR, "employees");
745
- if (fs.existsSync(employeesDir)) {
746
- const empEntries = fs.readdirSync(employeesDir, { withFileTypes: true });
747
- for (const e of empEntries) {
748
- if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
749
- employees.push(e.name.replace(/\.ya?ml$/, ""));
750
- }
751
- }
752
- }
753
- // Scan inside each department directory for YAML files (excluding department.yaml)
754
- for (const dept of departments) {
755
- const deptDir = path.join(ORG_DIR, dept);
756
- const deptEntries = fs.readdirSync(deptDir, { withFileTypes: true });
757
- for (const e of deptEntries) {
758
- if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml")) && e.name !== "department.yaml") {
759
- employees.push(e.name.replace(/\.ya?ml$/, ""));
760
- }
761
- }
762
- }
763
- return json(res, { departments, employees });
827
+ const { scanOrg } = await import("./org.js");
828
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
829
+ const orgRegistry = scanOrg();
830
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
831
+ const employees = hierarchy.sorted.map((name) => {
832
+ const node = hierarchy.nodes[name];
833
+ const emp = node.employee;
834
+ const { persona, ...rest } = emp;
835
+ return {
836
+ ...rest,
837
+ parentName: node.parentName,
838
+ directReports: node.directReports,
839
+ depth: node.depth,
840
+ chain: node.chain,
841
+ };
842
+ });
843
+ return json(res, {
844
+ departments,
845
+ employees,
846
+ hierarchy: {
847
+ root: hierarchy.root,
848
+ sorted: hierarchy.sorted,
849
+ warnings: hierarchy.warnings,
850
+ },
851
+ });
764
852
  }
765
853
  // GET /api/org/employees/:name
766
854
  params = matchRoute("/api/org/employees/:name", pathname);
767
855
  if (method === "GET" && params) {
768
- const candidates = [
769
- path.join(ORG_DIR, "employees", `${params.name}.yaml`),
770
- path.join(ORG_DIR, "employees", `${params.name}.yml`),
771
- path.join(ORG_DIR, `${params.name}.yaml`),
772
- path.join(ORG_DIR, `${params.name}.yml`),
773
- ];
774
- // Also search inside each department directory
775
- if (fs.existsSync(ORG_DIR)) {
776
- const dirs = fs.readdirSync(ORG_DIR, { withFileTypes: true }).filter((e) => e.isDirectory());
777
- for (const dir of dirs) {
778
- candidates.push(path.join(ORG_DIR, dir.name, `${params.name}.yaml`));
779
- candidates.push(path.join(ORG_DIR, dir.name, `${params.name}.yml`));
780
- }
856
+ const { scanOrg } = await import("./org.js");
857
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
858
+ const orgRegistry = scanOrg();
859
+ const emp = orgRegistry.get(params.name);
860
+ if (!emp)
861
+ return notFound(res);
862
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
863
+ const node = hierarchy.nodes[params.name];
864
+ return json(res, {
865
+ ...emp,
866
+ parentName: node?.parentName ?? null,
867
+ directReports: node?.directReports ?? [],
868
+ depth: node?.depth ?? 0,
869
+ chain: node?.chain ?? [params.name],
870
+ });
871
+ }
872
+ // PATCH /api/org/employees/:name — update employee fields (currently only alwaysNotify)
873
+ params = matchRoute("/api/org/employees/:name", pathname);
874
+ if (method === "PATCH" && params) {
875
+ const _parsed = await readJsonBody(req, res);
876
+ if (!_parsed.ok)
877
+ return;
878
+ const body = _parsed.body;
879
+ const { updateEmployeeYaml } = await import("./org.js");
880
+ const updated = updateEmployeeYaml(params.name, {
881
+ alwaysNotify: typeof body.alwaysNotify === "boolean" ? body.alwaysNotify : undefined,
882
+ });
883
+ if (!updated)
884
+ return notFound(res);
885
+ context.emit("org:updated", { employee: params.name });
886
+ return json(res, { status: "ok" });
887
+ }
888
+ // GET /api/org/services — list all cross-department services
889
+ if (method === "GET" && pathname === "/api/org/services") {
890
+ const { scanOrg } = await import("./org.js");
891
+ const { buildServiceRegistry } = await import("./services.js");
892
+ const orgRegistry = scanOrg();
893
+ const services = buildServiceRegistry(orgRegistry);
894
+ const result = Array.from(services.values()).map((entry) => ({
895
+ name: entry.declaration.name,
896
+ description: entry.declaration.description,
897
+ provider: {
898
+ name: entry.provider.name,
899
+ displayName: entry.provider.displayName,
900
+ department: entry.provider.department,
901
+ rank: entry.provider.rank,
902
+ },
903
+ }));
904
+ return json(res, { services: result });
905
+ }
906
+ // POST /api/org/cross-request — route a service request to the provider
907
+ if (method === "POST" && pathname === "/api/org/cross-request") {
908
+ const parsed = await readJsonBody(req, res);
909
+ if (!parsed.ok)
910
+ return;
911
+ const body = parsed.body;
912
+ const { fromEmployee, service, prompt, parentSessionId } = body;
913
+ if (!fromEmployee || !service || !prompt) {
914
+ return badRequest(res, "Missing required fields: fromEmployee, service, prompt");
781
915
  }
782
- const filePath = candidates.find((c) => fs.existsSync(c));
783
- if (!filePath)
916
+ const { scanOrg } = await import("./org.js");
917
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
918
+ const { buildServiceRegistry, buildRoutePath, resolveManagerChain } = await import("./services.js");
919
+ const orgRegistry = scanOrg();
920
+ const requester = orgRegistry.get(fromEmployee);
921
+ if (!requester)
784
922
  return notFound(res);
785
- const content = yaml.load(fs.readFileSync(filePath, "utf-8"));
786
- return json(res, content);
923
+ const services = buildServiceRegistry(orgRegistry);
924
+ const entry = services.get(service);
925
+ if (!entry) {
926
+ return json(res, { error: `Service "${service}" not found` }, 404);
927
+ }
928
+ const hierarchy = resolveOrgHierarchy(orgRegistry);
929
+ const route = buildRoutePath(fromEmployee, entry.provider.name, hierarchy);
930
+ const managers = resolveManagerChain(route, hierarchy);
931
+ const crossBrief = `## Cross-service request
932
+
933
+ **From**: ${requester.displayName} (${requester.department})
934
+ **Service**: ${service} — ${entry.declaration.description}
935
+
936
+ ### Request
937
+ ${prompt}
938
+
939
+ ---
940
+ Handle this as a priority request from a colleague.`;
941
+ const config = context.getConfig();
942
+ const session = createSession({
943
+ engine: entry.provider.engine || config.engines.default,
944
+ model: entry.provider.model || undefined,
945
+ source: "cross-request",
946
+ sourceRef: `cross:${fromEmployee}:${service}`,
947
+ connector: "web",
948
+ sessionKey: `cross:${Date.now()}`,
949
+ replyContext: { source: "cross-request" },
950
+ employee: entry.provider.name,
951
+ parentSessionId: parentSessionId || undefined,
952
+ prompt: crossBrief,
953
+ portalName: config.portal?.portalName,
954
+ title: `Cross-request: ${fromEmployee} → ${service}`,
955
+ });
956
+ insertMessage(session.id, "user", crossBrief);
957
+ logger.info(`Cross-request session created: ${session.id} (${fromEmployee} → ${service} → ${entry.provider.name})`);
958
+ return json(res, {
959
+ sessionId: session.id,
960
+ provider: {
961
+ name: entry.provider.name,
962
+ displayName: entry.provider.displayName,
963
+ department: entry.provider.department,
964
+ },
965
+ route,
966
+ managers: managers.map((m) => m.employee.name),
967
+ service,
968
+ }, 201);
787
969
  }
788
970
  // GET /api/org/departments/:name/board
789
971
  params = matchRoute("/api/org/departments/:name/board", pathname);
@@ -944,18 +1126,34 @@ export async function handleApiRequest(req, res, context) {
944
1126
  if (method === "GET" && pathname === "/api/config") {
945
1127
  const config = context.getConfig();
946
1128
  // Sanitize: remove any secrets/tokens from connectors
947
- const sanitized = {
948
- ...config,
949
- connectors: Object.fromEntries(Object.entries(config.connectors || {}).map(([k, v]) => [
950
- k,
951
- {
1129
+ const rawConnectors = config.connectors || {};
1130
+ const sanitizedConnectors = {};
1131
+ for (const [k, v] of Object.entries(rawConnectors)) {
1132
+ if (k === "instances" && Array.isArray(v)) {
1133
+ sanitizedConnectors.instances = v.map((inst) => ({
1134
+ ...inst,
1135
+ token: inst?.token ? "***" : undefined,
1136
+ signingSecret: inst?.signingSecret ? "***" : undefined,
1137
+ botToken: inst?.botToken ? "***" : undefined,
1138
+ appToken: inst?.appToken ? "***" : undefined,
1139
+ }));
1140
+ }
1141
+ else if (v && typeof v === "object") {
1142
+ sanitizedConnectors[k] = {
952
1143
  ...v,
953
1144
  token: v?.token ? "***" : undefined,
954
1145
  signingSecret: v?.signingSecret ? "***" : undefined,
955
1146
  botToken: v?.botToken ? "***" : undefined,
956
1147
  appToken: v?.appToken ? "***" : undefined,
957
- },
958
- ])),
1148
+ };
1149
+ }
1150
+ else {
1151
+ sanitizedConnectors[k] = v;
1152
+ }
1153
+ }
1154
+ const sanitized = {
1155
+ ...config,
1156
+ connectors: sanitizedConnectors,
959
1157
  };
960
1158
  return json(res, sanitized);
961
1159
  }
@@ -1035,9 +1233,26 @@ export async function handleApiRequest(req, res, context) {
1035
1233
  const lines = allLines.slice(-n);
1036
1234
  return json(res, { lines });
1037
1235
  }
1038
- // POST /api/connectors/discord/incomingreceive proxied Discord messages from primary instance
1039
- if (method === "POST" && pathname === "/api/connectors/discord/incoming") {
1040
- const connector = context.connectors.get("discord");
1236
+ // POST /api/connectors/reloadstop all instance connectors and restart from config
1237
+ if (method === "POST" && pathname === "/api/connectors/reload") {
1238
+ if (!context.reloadConnectorInstances) {
1239
+ return json(res, { error: "Connector reload not available" }, 501);
1240
+ }
1241
+ try {
1242
+ const result = await context.reloadConnectorInstances();
1243
+ context.emit("connectors:reloaded", result);
1244
+ return json(res, result);
1245
+ }
1246
+ catch (err) {
1247
+ return json(res, { error: err instanceof Error ? err.message : String(err) }, 500);
1248
+ }
1249
+ }
1250
+ // POST /api/connectors/:id/incoming — receive proxied Discord messages from primary instance
1251
+ // Supports both the legacy /api/connectors/discord/incoming and named instance ids
1252
+ params = matchRoute("/api/connectors/:id/incoming", pathname);
1253
+ if (method === "POST" && params && params.id) {
1254
+ // Try the exact instance id first, then fall back to "discord" for the legacy path
1255
+ const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
1041
1256
  if (!connector)
1042
1257
  return notFound(res);
1043
1258
  if (!("deliverMessage" in connector)) {
@@ -1063,7 +1278,7 @@ export async function handleApiRequest(req, res, context) {
1063
1278
  return att;
1064
1279
  }));
1065
1280
  const incomingMsg = {
1066
- connector: "discord",
1281
+ connector: params.id,
1067
1282
  source: "discord",
1068
1283
  sessionKey: body.sessionKey,
1069
1284
  channel: body.channel,
@@ -1081,9 +1296,11 @@ export async function handleApiRequest(req, res, context) {
1081
1296
  connector.deliverMessage(incomingMsg);
1082
1297
  return json(res, { status: "delivered" });
1083
1298
  }
1084
- // POST /api/connectors/discord/proxy — proxy connector operations from remote instances
1085
- if (method === "POST" && pathname === "/api/connectors/discord/proxy") {
1086
- const connector = context.connectors.get("discord");
1299
+ // POST /api/connectors/:id/proxy — proxy connector operations from remote instances
1300
+ // Supports both the legacy /api/connectors/discord/proxy and named instance ids
1301
+ params = matchRoute("/api/connectors/:id/proxy", pathname);
1302
+ if (method === "POST" && params && params.id) {
1303
+ const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
1087
1304
  if (!connector)
1088
1305
  return notFound(res);
1089
1306
  const _parsed = await readJsonBody(req, res);
@@ -1159,8 +1376,10 @@ export async function handleApiRequest(req, res, context) {
1159
1376
  }
1160
1377
  // GET /api/connectors — list available connectors
1161
1378
  if (method === "GET" && pathname === "/api/connectors") {
1162
- const connectors = Array.from(context.connectors.values()).map((connector) => ({
1379
+ const connectors = Array.from(context.connectors.entries()).map(([instanceId, connector]) => ({
1163
1380
  name: connector.name,
1381
+ instanceId,
1382
+ employee: connector.getEmployee?.() ?? undefined,
1164
1383
  ...connector.getHealth(),
1165
1384
  }));
1166
1385
  return json(res, connectors);
@@ -1665,15 +1884,18 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1665
1884
  lastActivity: new Date().toISOString(),
1666
1885
  });
1667
1886
  }
1887
+ // If this session has an assigned employee, load their persona
1888
+ let employee;
1889
+ if (currentSession.employee) {
1890
+ const { findEmployee } = await import("./org.js");
1891
+ const { scanOrg } = await import("./org.js");
1892
+ const registry = scanOrg();
1893
+ employee = findEmployee(currentSession.employee, registry);
1894
+ }
1895
+ const { scanOrg: scanOrgForHierarchy } = await import("./org.js");
1896
+ const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
1897
+ const orgHierarchy = resolveOrgHierarchy(scanOrgForHierarchy());
1668
1898
  try {
1669
- // If this session has an assigned employee, load their persona
1670
- let employee;
1671
- if (currentSession.employee) {
1672
- const { findEmployee } = await import("./org.js");
1673
- const { scanOrg } = await import("./org.js");
1674
- const registry = scanOrg();
1675
- employee = findEmployee(currentSession.employee, registry);
1676
- }
1677
1899
  const systemPrompt = buildContext({
1678
1900
  source: "web",
1679
1901
  channel: currentSession.sourceRef,
@@ -1682,10 +1904,13 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1682
1904
  connectors: Array.from(context.connectors.keys()),
1683
1905
  config,
1684
1906
  sessionId: currentSession.id,
1907
+ hierarchy: orgHierarchy,
1685
1908
  });
1686
1909
  const engineConfig = currentSession.engine === "codex"
1687
1910
  ? config.engines.codex
1688
- : config.engines.claude;
1911
+ : currentSession.engine === "gemini"
1912
+ ? config.engines.gemini ?? config.engines.claude
1913
+ : config.engines.claude;
1689
1914
  const effortLevel = resolveEffort(engineConfig, currentSession, employee);
1690
1915
  let lastHeartbeatAt = 0;
1691
1916
  const runHeartbeat = setInterval(() => {
@@ -1831,7 +2056,7 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1831
2056
  lastError: fallbackResult.error ?? null,
1832
2057
  });
1833
2058
  if (completedFallback) {
1834
- notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs });
2059
+ notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
1835
2060
  }
1836
2061
  context.emit("session:completed", {
1837
2062
  sessionId: currentSession.id,
@@ -1941,7 +2166,7 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1941
2166
  if (completedAfterRetry) {
1942
2167
  notifyRateLimitResumed(completedAfterRetry);
1943
2168
  notifyDiscordChannel(`✅ Claude usage limit cleared. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} resumed.`);
1944
- notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs });
2169
+ notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
1945
2170
  }
1946
2171
  context.emit("session:completed", {
1947
2172
  sessionId: currentSession.id,
@@ -1963,7 +2188,7 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1963
2188
  lastError: "Claude usage limit did not clear in time",
1964
2189
  });
1965
2190
  if (erroredSession) {
1966
- notifyParentSession(erroredSession, { error: "Claude usage limit did not clear in time" });
2191
+ notifyParentSession(erroredSession, { error: "Claude usage limit did not clear in time" }, { alwaysNotify: employee?.alwaysNotify });
1967
2192
  }
1968
2193
  context.emit("session:completed", {
1969
2194
  sessionId: currentSession.id,
@@ -1996,7 +2221,7 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
1996
2221
  }
1997
2222
  }
1998
2223
  if (completedSession) {
1999
- notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs });
2224
+ notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2000
2225
  }
2001
2226
  context.emit("session:completed", {
2002
2227
  sessionId: currentSession.id,
@@ -2023,7 +2248,7 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
2023
2248
  lastError: errMsg,
2024
2249
  });
2025
2250
  if (erroredSession) {
2026
- notifyParentSession(erroredSession, { error: errMsg });
2251
+ notifyParentSession(erroredSession, { error: errMsg }, { alwaysNotify: employee?.alwaysNotify });
2027
2252
  }
2028
2253
  context.emit("session:completed", {
2029
2254
  sessionId: currentSession.id,