@syengup/friday-channel-next 0.1.30 → 0.1.37

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 (154) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +1 -1
  3. package/dist/src/agent/abort-run.d.ts +12 -1
  4. package/dist/src/agent/abort-run.js +24 -9
  5. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  6. package/dist/src/agent/media-bridge.d.ts +8 -1
  7. package/dist/src/agent/media-bridge.js +23 -2
  8. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  9. package/dist/src/agent/node-pairing-bridge.js +6 -2
  10. package/dist/src/agent/subagent-registry.js +0 -3
  11. package/dist/src/agent-forward-runtime.d.ts +15 -0
  12. package/dist/src/agent-forward-runtime.js +2 -0
  13. package/dist/src/agent-id.d.ts +8 -0
  14. package/dist/src/agent-id.js +21 -0
  15. package/dist/src/channel-actions.js +48 -15
  16. package/dist/src/channel.js +22 -3
  17. package/dist/src/collect-message-media-paths.js +10 -1
  18. package/dist/src/friday-session.js +34 -10
  19. package/dist/src/history/normalize-message.js +22 -8
  20. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  21. package/dist/src/http/handlers/agent-config.js +188 -0
  22. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  23. package/dist/src/http/handlers/agent-files.js +137 -0
  24. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  25. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  26. package/dist/src/http/handlers/agents-list.js +1 -19
  27. package/dist/src/http/handlers/cancel.js +14 -6
  28. package/dist/src/http/handlers/device-approve.js +3 -1
  29. package/dist/src/http/handlers/files-download.js +6 -8
  30. package/dist/src/http/handlers/files.d.ts +16 -0
  31. package/dist/src/http/handlers/files.js +81 -13
  32. package/dist/src/http/handlers/health.js +18 -4
  33. package/dist/src/http/handlers/history-messages.js +1 -1
  34. package/dist/src/http/handlers/history-sessions.js +5 -3
  35. package/dist/src/http/handlers/messages.js +33 -14
  36. package/dist/src/http/handlers/models-list.d.ts +5 -0
  37. package/dist/src/http/handlers/models-list.js +9 -1
  38. package/dist/src/http/handlers/nodes-approve.js +1 -6
  39. package/dist/src/http/handlers/plugin-info.js +1 -1
  40. package/dist/src/http/handlers/sessions-settings.js +15 -10
  41. package/dist/src/http/server.js +27 -2
  42. package/dist/src/link-preview/og-parse.js +3 -1
  43. package/dist/src/link-preview/ssrf-guard.js +6 -2
  44. package/dist/src/media-fetch.js +4 -1
  45. package/dist/src/plugin-install-info.js +4 -1
  46. package/dist/src/session/session-manager.js +9 -3
  47. package/dist/src/session-usage-store.js +3 -1
  48. package/dist/src/skills-discovery.d.ts +59 -0
  49. package/dist/src/skills-discovery.js +252 -0
  50. package/dist/src/sse/offline-queue.js +4 -1
  51. package/dist/src/thinking-levels.d.ts +21 -0
  52. package/dist/src/thinking-levels.js +48 -0
  53. package/dist/src/tool-catalog.d.ts +53 -0
  54. package/dist/src/tool-catalog.js +191 -0
  55. package/dist/src/upgrade-runtime.d.ts +1 -1
  56. package/dist/src/version.js +4 -2
  57. package/index.ts +43 -35
  58. package/install.js +131 -43
  59. package/package.json +10 -1
  60. package/src/agent/abort-run.ts +23 -8
  61. package/src/agent/dispatch-bridge.ts +2 -1
  62. package/src/agent/media-bridge.test.ts +71 -0
  63. package/src/agent/media-bridge.ts +30 -1
  64. package/src/agent/node-pairing-bridge.ts +29 -15
  65. package/src/agent/run-usage-accumulator.ts +4 -2
  66. package/src/agent/subagent-registry.ts +0 -4
  67. package/src/agent-forward-runtime.ts +11 -0
  68. package/src/agent-id.ts +24 -0
  69. package/src/agent-run-context-bridge.ts +3 -1
  70. package/src/channel-actions.test.ts +57 -4
  71. package/src/channel-actions.ts +41 -15
  72. package/src/channel.lifecycle.test.ts +41 -0
  73. package/src/channel.outbound.test.ts +18 -4
  74. package/src/channel.ts +140 -120
  75. package/src/collect-message-media-paths.ts +15 -6
  76. package/src/config.ts +1 -4
  77. package/src/e2e/agents-list.e2e.test.ts +9 -2
  78. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  79. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  80. package/src/e2e/auto-approve.integration.test.ts +13 -7
  81. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  82. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  83. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  84. package/src/e2e/send-text.e2e.test.ts +11 -2
  85. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  86. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  87. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  88. package/src/e2e/subagent.e2e.test.ts +136 -53
  89. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  90. package/src/friday-session.forward-agent.test.ts +44 -12
  91. package/src/friday-session.ts +44 -20
  92. package/src/history/normalize-message.test.ts +35 -8
  93. package/src/history/normalize-message.ts +24 -12
  94. package/src/history/read-transcript.ts +1 -4
  95. package/src/http/handlers/agent-config.test.ts +212 -0
  96. package/src/http/handlers/agent-config.ts +232 -0
  97. package/src/http/handlers/agent-files.test.ts +136 -0
  98. package/src/http/handlers/agent-files.ts +149 -0
  99. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  100. package/src/http/handlers/agents-list.test.ts +1 -5
  101. package/src/http/handlers/agents-list.ts +1 -22
  102. package/src/http/handlers/cancel.test.ts +23 -4
  103. package/src/http/handlers/cancel.ts +14 -6
  104. package/src/http/handlers/device-approve.test.ts +12 -3
  105. package/src/http/handlers/device-approve.ts +33 -21
  106. package/src/http/handlers/files-download.ts +17 -13
  107. package/src/http/handlers/files.test.ts +120 -0
  108. package/src/http/handlers/files.ts +115 -17
  109. package/src/http/handlers/health.test.ts +43 -11
  110. package/src/http/handlers/health.ts +22 -6
  111. package/src/http/handlers/history-messages.test.ts +51 -9
  112. package/src/http/handlers/history-messages.ts +4 -1
  113. package/src/http/handlers/history-sessions.test.ts +46 -9
  114. package/src/http/handlers/history-sessions.ts +5 -3
  115. package/src/http/handlers/history-set-title.test.ts +14 -5
  116. package/src/http/handlers/link-preview.test.ts +57 -16
  117. package/src/http/handlers/link-preview.ts +4 -1
  118. package/src/http/handlers/messages.test.ts +12 -8
  119. package/src/http/handlers/messages.ts +64 -21
  120. package/src/http/handlers/models-list.test.ts +114 -0
  121. package/src/http/handlers/models-list.ts +26 -8
  122. package/src/http/handlers/nodes-approve.test.ts +15 -4
  123. package/src/http/handlers/nodes-approve.ts +38 -40
  124. package/src/http/handlers/plugin-info.ts +5 -6
  125. package/src/http/handlers/plugin-upgrade.ts +4 -1
  126. package/src/http/handlers/sessions-settings.ts +16 -11
  127. package/src/http/handlers/sse.ts +3 -1
  128. package/src/http/server.ts +33 -6
  129. package/src/link-preview/og-parse.test.ts +6 -2
  130. package/src/link-preview/og-parse.ts +10 -3
  131. package/src/link-preview/preview-service.ts +4 -1
  132. package/src/link-preview/ssrf-guard.test.ts +78 -16
  133. package/src/link-preview/ssrf-guard.ts +7 -2
  134. package/src/media-fetch.test.ts +8 -3
  135. package/src/media-fetch.ts +5 -3
  136. package/src/openclaw.d.ts +41 -10
  137. package/src/plugin-install-info.ts +20 -9
  138. package/src/run-metadata.ts +2 -1
  139. package/src/session/session-manager.ts +19 -11
  140. package/src/session-usage-snapshot.ts +3 -1
  141. package/src/session-usage-store.ts +3 -1
  142. package/src/skills-discovery.test.ts +152 -0
  143. package/src/skills-discovery.ts +264 -0
  144. package/src/sse/emitter.test.ts +1 -1
  145. package/src/sse/emitter.ts +9 -3
  146. package/src/sse/offline-queue.ts +17 -8
  147. package/src/test-support/app-simulator.ts +17 -3
  148. package/src/test-support/mock-dispatch.ts +17 -4
  149. package/src/thinking-levels.test.ts +143 -0
  150. package/src/thinking-levels.ts +70 -0
  151. package/src/tool-catalog.ts +261 -0
  152. package/src/upgrade-runtime.ts +4 -2
  153. package/src/version.ts +6 -2
  154. package/tsconfig.json +1 -1
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ discoverAvailableSkills,
7
+ enabledExtensionNames,
8
+ resetOpenClawRootCacheForTest,
9
+ } from "./skills-discovery.js";
10
+ import {
11
+ setFridayAgentForwardRuntime,
12
+ resetFridayAgentForwardRuntimeForTest,
13
+ } from "./agent-forward-runtime.js";
14
+
15
+ /** Create `<parent>/<id>/SKILL.md` for each id. */
16
+ function makeSkills(parent: string, ids: string[]): void {
17
+ for (const id of ids) {
18
+ fs.mkdirSync(path.join(parent, id), { recursive: true });
19
+ fs.writeFileSync(path.join(parent, id, "SKILL.md"), "# " + id);
20
+ }
21
+ }
22
+
23
+ describe("discoverAvailableSkills", () => {
24
+ let root: string;
25
+
26
+ function wire(configRoot: string, cfg: unknown): void {
27
+ setFridayAgentForwardRuntime({
28
+ runtime: {
29
+ agent: {
30
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
31
+ // main → <configRoot>/workspace ; others → <configRoot>/workspace/agents/<id>
32
+ resolveAgentWorkspaceDir: (_c: unknown, id: string) =>
33
+ id === "main"
34
+ ? path.join(configRoot, "workspace")
35
+ : path.join(configRoot, "workspace", "agents", id),
36
+ },
37
+ config: { current: () => cfg },
38
+ },
39
+ } as never);
40
+ }
41
+
42
+ afterEach(() => {
43
+ resetFridayAgentForwardRuntimeForTest();
44
+ resetOpenClawRootCacheForTest();
45
+ if (root) fs.rmSync(root, { recursive: true, force: true });
46
+ });
47
+
48
+ it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
49
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
50
+ const configRoot = path.join(root, "configdir");
51
+ const extraDir = path.join(root, "extra");
52
+
53
+ // Default agent "main" workspace — its skills must NOT leak into other agents' catalogs.
54
+ makeSkills(path.join(configRoot, "workspace", "skills"), ["alpha"]);
55
+ // operator's own workspace — opencli also lives in the managed dir below, to prove
56
+ // workspace > installed precedence in dedup.
57
+ makeSkills(path.join(configRoot, "workspace", "agents", "operator", "skills"), [
58
+ "beta",
59
+ "opencli",
60
+ ]);
61
+ // managed dir: <configDir>/skills (sibling of the default workspace)
62
+ makeSkills(path.join(configRoot, "skills"), ["managed-one", "opencli"]);
63
+ // config extraDirs
64
+ makeSkills(extraDir, ["gamma"]);
65
+
66
+ const cfg = {
67
+ agents: { list: [{ id: "main", default: true }, { id: "operator" }] },
68
+ skills: { load: { extraDirs: [extraDir] } },
69
+ };
70
+ wire(configRoot, cfg);
71
+
72
+ const result = discoverAvailableSkills(cfg, "operator");
73
+ // "alpha" is main-only → absent for operator (regression guard for the main-leak bug).
74
+ expect(result.map((s) => s.id)).toEqual(["beta", "gamma", "managed-one", "opencli"]);
75
+ const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
76
+ expect(bySource).toEqual({
77
+ beta: "workspace",
78
+ opencli: "workspace", // workspace wins over the managed-dir duplicate
79
+ "managed-one": "installed",
80
+ gamma: "extra",
81
+ });
82
+ });
83
+
84
+ it("ignores directories without SKILL.md", () => {
85
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
86
+ const configRoot = path.join(root, "configdir");
87
+ const skillsDir = path.join(configRoot, "workspace", "skills");
88
+ makeSkills(skillsDir, ["real"]);
89
+ fs.mkdirSync(path.join(skillsDir, "empty-dir"), { recursive: true }); // no SKILL.md
90
+
91
+ const cfg = { agents: { list: [{ id: "main", default: true }] } };
92
+ wire(configRoot, cfg);
93
+
94
+ expect(discoverAvailableSkills(cfg, "main").map((s) => s.id)).toEqual(["real"]);
95
+ });
96
+
97
+ it("uses the SKILL.md frontmatter name over the dir name, and finds nested skills", () => {
98
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
99
+ const configRoot = path.join(root, "configdir");
100
+ const skillsDir = path.join(configRoot, "workspace", "skills");
101
+
102
+ // dir name != declared name
103
+ fs.mkdirSync(path.join(skillsDir, "self-improving-agent"), { recursive: true });
104
+ fs.writeFileSync(
105
+ path.join(skillsDir, "self-improving-agent", "SKILL.md"),
106
+ '---\nname: self-improvement\ndescription: "x"\n---\n# body',
107
+ );
108
+ // nested skill (redskill-style): SKILL.md two levels under the skills dir
109
+ const nested = path.join(skillsDir, "luckincoffee-mycoffeeskill", "my-coffee-skill");
110
+ fs.mkdirSync(nested, { recursive: true });
111
+ fs.writeFileSync(path.join(nested, "SKILL.md"), "---\nname: my-coffee\n---\n# body");
112
+
113
+ const cfg = { agents: { list: [{ id: "main", default: true }] } };
114
+ wire(configRoot, cfg);
115
+
116
+ const result = discoverAvailableSkills(cfg, "main");
117
+ expect(result.map((s) => s.id)).toEqual(["my-coffee", "self-improvement"]);
118
+ expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
119
+ });
120
+
121
+ it("returns [] without throwing when nothing is resolvable", () => {
122
+ resetFridayAgentForwardRuntimeForTest();
123
+ expect(discoverAvailableSkills({}, "main")).toEqual([]);
124
+ });
125
+ });
126
+
127
+ describe("enabledExtensionNames", () => {
128
+ it("unions plugins.allow with entries[name].enabled === true", () => {
129
+ const cfg = {
130
+ plugins: {
131
+ allow: ["browser", "canvas"],
132
+ entries: {
133
+ browser: { enabled: true },
134
+ telegram: { enabled: true },
135
+ tavily: { enabled: false },
136
+ "open-prose": {},
137
+ },
138
+ },
139
+ };
140
+ const names = enabledExtensionNames(cfg);
141
+ expect(names.has("browser")).toBe(true);
142
+ expect(names.has("canvas")).toBe(true);
143
+ expect(names.has("telegram")).toBe(true); // from entries.enabled
144
+ expect(names.has("tavily")).toBe(false); // enabled:false
145
+ expect(names.has("open-prose")).toBe(false); // no enabled flag
146
+ });
147
+
148
+ it("returns an empty set when plugins config is absent", () => {
149
+ expect(enabledExtensionNames({}).size).toBe(0);
150
+ expect(enabledExtensionNames(undefined).size).toBe(0);
151
+ });
152
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Discover the full catalog of skills an agent can load — the selectable set the
3
+ * app's skill-management UI offers, matching what OpenClaw's ControlUI shows.
4
+ *
5
+ * OpenClaw resolves an agent's loadable skills from several directories. None of
6
+ * the core's skill-discovery functions are reachable through a stable plugin-sdk
7
+ * specifier (they live only in hash-named chunks), so instead of deep-importing
8
+ * brittle core code we scan the same DIRECTORIES core scans — a far more stable
9
+ * coupling (directory conventions change far less often than bundled chunk names),
10
+ * and every access is guarded so a missing/renamed source just yields fewer skills
11
+ * rather than an error. A skill is a sub-directory containing `SKILL.md` (the same
12
+ * marker core's `loadSkillsFromDir` uses).
13
+ *
14
+ * Each discovered skill is tagged with a `source` category for the UI:
15
+ * - workspace : the TARGET agent's own workspace `skills/` only — mirroring ControlUI,
16
+ * which scans the single workspace resolved for that agent and never folds
17
+ * in another agent's workspace (main is just another agent, not a shared pool)
18
+ * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
+ * - built-in : bundled core skills (`<openclaw>/skills`)
20
+ * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
+ * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
+ * `skills.load.extraDirs` — mirrors ControlUI's "EXTRA" bucket
23
+ * (core tags extension skills `source: "extension"`).
24
+ *
25
+ * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
26
+ *
27
+ * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
+ * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
29
+ * `self-improving-agent/` dir declares `name: self-improvement`). The `description:`
30
+ * frontmatter field is surfaced too. Discovery is RECURSIVE (mirroring core's
31
+ * `loadSkills`): some skills nest the `SKILL.md` a few levels deep (e.g. redskill
32
+ * installs at `<pkg>/<sub>/<skill>/SKILL.md`).
33
+ *
34
+ * NOT included (out of scope for a name catalog): ClawHub remote-only skills and
35
+ * per-skill eligibility/`disabled` flags. The enabled set is the agent's own
36
+ * `skills[]` config, already returned separately by the config view.
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+ import { createRequire } from "node:module";
42
+ import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
43
+ import { DEFAULT_AGENT_ID, normalizeAgentId } from "./agent-id.js";
44
+
45
+ /** Depth limit for the recursive walk under each source dir (skills nest a few levels). */
46
+ const MAX_SKILL_WALK_DEPTH = 6;
47
+ const IGNORED_WALK_DIRS = new Set(["node_modules", ".git"]);
48
+
49
+ export type SkillSource = "workspace" | "built-in" | "installed" | "extra";
50
+
51
+ export interface DiscoveredSkill {
52
+ id: string;
53
+ description?: string;
54
+ source: SkillSource;
55
+ }
56
+
57
+ /** Extract `name`/`description` from a SKILL.md YAML frontmatter block. */
58
+ function parseSkillFrontmatter(content: string): { name?: string; description?: string } {
59
+ const lines = content.split(/\r?\n/);
60
+ if (lines[0]?.trim() !== "---") return {};
61
+ let name: string | undefined;
62
+ let description: string | undefined;
63
+ for (let i = 1; i < lines.length; i++) {
64
+ if (lines[i].trim() === "---") break;
65
+ const m = /^(name|description)\s*:\s*(.+?)\s*$/.exec(lines[i]);
66
+ if (!m) continue;
67
+ let v = m[2].trim();
68
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
69
+ v = v.slice(1, -1);
70
+ }
71
+ v = v.trim();
72
+ if (!v) continue;
73
+ if (m[1] === "name" && name === undefined) name = v;
74
+ else if (m[1] === "description" && description === undefined) description = v;
75
+ }
76
+ return { name, description };
77
+ }
78
+
79
+ /**
80
+ * Collect skills reachable under `root`, tagging each with `source`. A directory
81
+ * containing `SKILL.md` IS a skill (id = frontmatter `name`, else dir name) and is
82
+ * not descended into further; other directories are recursed up to a bounded depth.
83
+ * First occurrence of an id wins (call higher-priority sources first). Best-effort.
84
+ */
85
+ function collectSkills(
86
+ root: string,
87
+ source: SkillSource,
88
+ out: Map<string, DiscoveredSkill>,
89
+ depth = 0,
90
+ ): void {
91
+ if (depth > MAX_SKILL_WALK_DEPTH) return;
92
+ let entries: fs.Dirent[];
93
+ try {
94
+ entries = fs.readdirSync(root, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) {
99
+ let fm: { name?: string; description?: string } = {};
100
+ try {
101
+ fm = parseSkillFrontmatter(fs.readFileSync(path.join(root, "SKILL.md"), "utf-8"));
102
+ } catch {
103
+ // unreadable frontmatter → fall back to dir name, no description
104
+ }
105
+ const id = fm.name ?? path.basename(root);
106
+ if (!out.has(id)) out.set(id, { id, description: fm.description, source });
107
+ return; // a skill is a leaf — don't treat its internals as nested skills
108
+ }
109
+ for (const e of entries) {
110
+ if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
111
+ collectSkills(path.join(root, e.name), source, out, depth + 1);
112
+ }
113
+ }
114
+ }
115
+
116
+ let cachedOpenClawRoot: string | null | undefined;
117
+
118
+ /** Locate the installed `openclaw` package root (cached). Shared with tool-catalog discovery. */
119
+ export function resolveOpenClawRoot(): string | null {
120
+ if (cachedOpenClawRoot !== undefined) return cachedOpenClawRoot;
121
+ cachedOpenClawRoot = computeOpenClawRoot();
122
+ return cachedOpenClawRoot;
123
+ }
124
+
125
+ function computeOpenClawRoot(): string | null {
126
+ const starts: string[] = [];
127
+ // Primary: resolve a subpath this plugin already imports (works inside the gateway
128
+ // where `openclaw/*` is resolvable). Standalone (e.g. unit tests) this throws → skipped.
129
+ try {
130
+ starts.push(createRequire(import.meta.url).resolve("openclaw/plugin-sdk/plugin-entry"));
131
+ } catch {
132
+ // not resolvable outside the gateway runtime
133
+ }
134
+ // Fallback: the gateway process entry (`<openclaw>/dist/index.js`) — the plugin runs in it.
135
+ if (typeof process.argv[1] === "string") starts.push(process.argv[1]);
136
+
137
+ for (const start of starts) {
138
+ let dir = path.dirname(start);
139
+ for (let i = 0; i < 10 && dir !== path.dirname(dir); i++) {
140
+ try {
141
+ const pkgPath = path.join(dir, "package.json");
142
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: string };
143
+ if (pkg?.name === "openclaw") return dir;
144
+ } catch {
145
+ // keep walking up
146
+ }
147
+ dir = path.dirname(dir);
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * The set of bundled extensions enabled for this install — `plugins.allow` plus any
155
+ * `plugins.entries[name].enabled === true`. ControlUI only surfaces skills from
156
+ * enabled extensions, so we gate on the same set (extension dir name == plugin id).
157
+ */
158
+ export function enabledExtensionNames(cfg: unknown): Set<string> {
159
+ const plugins = (cfg as Record<string, unknown> | undefined)?.plugins as
160
+ | Record<string, unknown>
161
+ | undefined;
162
+ const names = new Set<string>();
163
+ const allow = plugins?.allow;
164
+ if (Array.isArray(allow)) for (const n of allow) if (typeof n === "string") names.add(n);
165
+ const entries = plugins?.entries as Record<string, unknown> | undefined;
166
+ if (entries && typeof entries === "object") {
167
+ for (const [name, val] of Object.entries(entries)) {
168
+ if (val && typeof val === "object" && (val as Record<string, unknown>).enabled === true)
169
+ names.add(name);
170
+ }
171
+ }
172
+ return names;
173
+ }
174
+
175
+ /**
176
+ * Bundled skill source dirs inside the openclaw install, tagged like ControlUI:
177
+ * core `<openclaw>/skills` → "built-in"; per-extension `dist/extensions/<ext>/skills`
178
+ * → "extra" (core tags these `source: "extension"`). Extension skills are included
179
+ * only when the extension is enabled, matching ControlUI's EXTRA bucket.
180
+ */
181
+ function bundledSkillSources(
182
+ enabledExtensions: Set<string>,
183
+ ): Array<{ dir: string; source: SkillSource }> {
184
+ const root = resolveOpenClawRoot();
185
+ if (!root) return [];
186
+ const out: Array<{ dir: string; source: SkillSource }> = [
187
+ { dir: path.join(root, "skills"), source: "built-in" },
188
+ ];
189
+ try {
190
+ const extRoot = path.join(root, "dist", "extensions");
191
+ for (const ext of fs.readdirSync(extRoot, { withFileTypes: true })) {
192
+ if (ext.isDirectory() && enabledExtensions.has(ext.name)) {
193
+ out.push({ dir: path.join(extRoot, ext.name, "skills"), source: "extra" });
194
+ }
195
+ }
196
+ } catch {
197
+ // no extensions dir on this build
198
+ }
199
+ return out;
200
+ }
201
+
202
+ function resolveDefaultAgentId(cfg: Record<string, unknown> | undefined): string {
203
+ const list = (cfg?.agents as Record<string, unknown> | undefined)?.list as
204
+ | Array<Record<string, unknown>>
205
+ | undefined;
206
+ if (Array.isArray(list) && list.length > 0) {
207
+ const def = list.find((a) => a?.default === true) ?? list[0];
208
+ if (def?.id) return normalizeAgentId(def.id);
209
+ }
210
+ return DEFAULT_AGENT_ID;
211
+ }
212
+
213
+ /**
214
+ * Full set of skills `agentId` can load, sorted by id, each tagged with its source
215
+ * category. Aggregates the TARGET agent's own workspace, the managed dir, config extra
216
+ * dirs, and bundled core/extension skills. Every source is optional and failure-tolerant.
217
+ */
218
+ export function discoverAvailableSkills(cfg: unknown, agentId: string): DiscoveredSkill[] {
219
+ const c = cfg as Record<string, unknown> | undefined;
220
+ const resolveWs = getFridayAgentForwardRuntime()?.resolveAgentWorkspaceDir;
221
+ const sources: Array<{ dir: string; source: SkillSource }> = [];
222
+
223
+ if (resolveWs) {
224
+ // Workspace skills come ONLY from the target agent's own workspace — matching ControlUI's
225
+ // `resolveSkillsAgentWorkspace`→`buildWorkspaceSkillStatus(workspaceDir)`, which scans the
226
+ // single resolved workspace. Folding in the default agent's workspace (the old behavior)
227
+ // leaked main's skills into every other agent's catalog.
228
+ try {
229
+ const ws = resolveWs(cfg, agentId);
230
+ if (ws) sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
231
+ } catch {
232
+ // skip unresolvable workspace
233
+ }
234
+ // Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
235
+ // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
236
+ // whereas non-default workspaces may be nested under it).
237
+ try {
238
+ const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
239
+ if (defaultWs)
240
+ sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
241
+ } catch {
242
+ // skip unresolvable managed dir
243
+ }
244
+ }
245
+
246
+ const extraDirs = (
247
+ (c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
248
+ )?.extraDirs;
249
+ if (Array.isArray(extraDirs)) {
250
+ for (const d of extraDirs)
251
+ if (typeof d === "string" && d.trim()) sources.push({ dir: d.trim(), source: "extra" });
252
+ }
253
+
254
+ sources.push(...bundledSkillSources(enabledExtensionNames(c)));
255
+
256
+ const out = new Map<string, DiscoveredSkill>();
257
+ for (const { dir, source } of sources) collectSkills(dir, source, out, 0);
258
+ return [...out.values()].sort((a, b) => a.id.localeCompare(b.id));
259
+ }
260
+
261
+ /** Test-only: reset the cached openclaw root. */
262
+ export function resetOpenClawRootCacheForTest(): void {
263
+ cachedOpenClawRoot = undefined;
264
+ }
@@ -78,7 +78,7 @@ describe("sseEmitter", () => {
78
78
  const body = c.writes.join("");
79
79
  expect(body).toContain("id: 2");
80
80
  expect(body).toContain("id: 3");
81
- expect(body).not.toContain("text\":\"a\"");
81
+ expect(body).not.toContain('text":"a"');
82
82
 
83
83
  sseEmitter.removeConnection("device-replay");
84
84
  });
@@ -4,7 +4,14 @@ import { fridaySseOfflineQueue } from "./offline-queue.js";
4
4
 
5
5
  const logger = createFridayNextLogger("sse", "info");
6
6
 
7
- export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping" | "subagent";
7
+ export type SseEventType =
8
+ | "connected"
9
+ | "agent"
10
+ | "deliver"
11
+ | "tool-hook"
12
+ | "outbound"
13
+ | "ping"
14
+ | "subagent";
8
15
 
9
16
  export interface SseEvent {
10
17
  type: SseEventType;
@@ -36,8 +43,7 @@ export class SseConnection {
36
43
 
37
44
  send(entry: BacklogEntry | SseEvent, flushNow?: boolean): void {
38
45
  if (this.closed) return;
39
- const normalized =
40
- "id" in entry && "event" in entry ? entry : { id: Date.now(), event: entry as SseEvent };
46
+ const normalized = "id" in entry && "event" in entry ? entry : { id: Date.now(), event: entry };
41
47
  const payload = JSON.stringify(normalized.event.data);
42
48
  this.pending.push(
43
49
  `id: ${normalized.id}\nevent: ${normalized.event.type}\ndata: ${payload}\n\n`,
@@ -21,7 +21,9 @@ export function setOfflineQueueBaseDirForTest(dir: string | null): void {
21
21
  export function resolveFridayNextEventsQueueDir(): string {
22
22
  if (testQueueBaseDir) return testQueueBaseDir;
23
23
  try {
24
- const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
24
+ const cfg = resolveFridayNextConfig(
25
+ getHostOpenClawConfigSnapshot(getFridayNextRuntime().config),
26
+ );
25
27
  return path.join(path.dirname(cfg.historyDir), "events-queue");
26
28
  } catch {
27
29
  return path.join(os.homedir(), ".openclaw", "friday-next", "events-queue");
@@ -71,7 +73,13 @@ export class FridaySseOfflineQueue {
71
73
  return this.scanMaxId(deviceId.trim().toUpperCase());
72
74
  }
73
75
 
74
- append(deviceId: string, id: number, event: string, data: Record<string, unknown>, backlogLimit: number): void {
76
+ append(
77
+ deviceId: string,
78
+ id: number,
79
+ event: string,
80
+ data: Record<string, unknown>,
81
+ backlogLimit: number,
82
+ ): void {
75
83
  if (event === "connected") return;
76
84
  this.ensureDir();
77
85
  const file = this.devicePath(deviceId);
@@ -119,7 +127,12 @@ export class FridaySseOfflineQueue {
119
127
  if (!line.trim()) continue;
120
128
  try {
121
129
  const o = JSON.parse(line) as PersistedSseEntry;
122
- if (typeof o.id === "number" && typeof o.event === "string" && o.data && typeof o.data === "object") {
130
+ if (
131
+ typeof o.id === "number" &&
132
+ typeof o.event === "string" &&
133
+ o.data &&
134
+ typeof o.data === "object"
135
+ ) {
123
136
  all.push(o);
124
137
  }
125
138
  } catch {
@@ -128,11 +141,7 @@ export class FridaySseOfflineQueue {
128
141
  }
129
142
  if (all.length <= keep) return;
130
143
  const slice = all.slice(-keep);
131
- fs.writeFileSync(
132
- file,
133
- slice.map((e) => JSON.stringify(e) + "\n").join(""),
134
- "utf8",
135
- );
144
+ fs.writeFileSync(file, slice.map((e) => JSON.stringify(e) + "\n").join(""), "utf8");
136
145
  }
137
146
  }
138
147
 
@@ -68,7 +68,9 @@ class MockRes extends Writable {
68
68
  }
69
69
 
70
70
  function createRouteHarness() {
71
- let routeHandler: ((req: Readable & { method?: string; url?: string }, res: Writable) => Promise<boolean>) | null = null;
71
+ let routeHandler:
72
+ | ((req: Readable & { method?: string; url?: string }, res: Writable) => Promise<boolean>)
73
+ | null = null;
72
74
  const fakeApi = {
73
75
  logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
74
76
  registerHttpRoute(route: { handler: (req: never, res: never) => Promise<boolean> }) {
@@ -193,7 +195,14 @@ export function createAppSimulator(opts?: { deviceId?: string; token?: string })
193
195
  });
194
196
  return { status: res.statusCode, body: jsonBody(res) };
195
197
  },
196
- async uploadFiles(parts: Array<{ name: string; filename: string; contentType: string; content: string | Buffer }>) {
198
+ async uploadFiles(
199
+ parts: Array<{
200
+ name: string;
201
+ filename: string;
202
+ contentType: string;
203
+ content: string | Buffer;
204
+ }>,
205
+ ) {
197
206
  const boundary = "----friday-next-e2e-boundary";
198
207
  const chunks: Buffer[] = [];
199
208
  for (const part of parts) {
@@ -235,7 +244,12 @@ export function createAppSimulator(opts?: { deviceId?: string; token?: string })
235
244
  const res = await request({ method: "OPTIONS", path, headers: { origin } });
236
245
  return { status: res.statusCode, headers: res.headers };
237
246
  },
238
- async rawRequest(arg: { method: string; path: string; headers?: Headers; body?: string | Buffer }) {
247
+ async rawRequest(arg: {
248
+ method: string;
249
+ path: string;
250
+ headers?: Headers;
251
+ body?: string | Buffer;
252
+ }) {
239
253
  const res = await request(arg);
240
254
  return { status: res.statusCode, body: res.body.toString("utf-8"), headers: res.headers };
241
255
  },
@@ -1,7 +1,12 @@
1
- import { __setMockFridayDispatchForTests, __resetMockFridayDispatchForTests } from "../agent/dispatch-bridge.js";
1
+ import {
2
+ __setMockFridayDispatchForTests,
3
+ __resetMockFridayDispatchForTests,
4
+ } from "../agent/dispatch-bridge.js";
2
5
  import { forwardAgentEventRaw } from "../friday-session.js";
3
6
 
4
- type DispatchArg = Parameters<typeof __setMockFridayDispatchForTests>[0] extends (arg: infer A) => unknown
7
+ type DispatchArg = Parameters<typeof __setMockFridayDispatchForTests>[0] extends (
8
+ arg: infer A,
9
+ ) => unknown
5
10
  ? A
6
11
  : never;
7
12
 
@@ -133,12 +138,20 @@ export class MockDispatchScript {
133
138
 
134
139
  block(text: string, mediaUrls: string[] = [], audioAsVoice = false): this {
135
140
  this.steps.push(async (_args, callbacks) => {
136
- await callbacks.deliver?.({ text, mediaUrls, audioAsVoice } as never, { kind: "block" } as never);
141
+ await callbacks.deliver?.(
142
+ { text, mediaUrls, audioAsVoice } as never,
143
+ { kind: "block" } as never,
144
+ );
137
145
  });
138
146
  return this;
139
147
  }
140
148
 
141
- deliverFinal(payload: { text: string; mediaUrls?: string[]; audioAsVoice?: boolean; isError?: boolean }): this {
149
+ deliverFinal(payload: {
150
+ text: string;
151
+ mediaUrls?: string[];
152
+ audioAsVoice?: boolean;
153
+ isError?: boolean;
154
+ }): this {
142
155
  this.steps.push(async (_args, callbacks) => {
143
156
  await callbacks.deliver?.(payload as never, { kind: "final" } as never);
144
157
  });