@syengup/friday-channel-next 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent/subagent-registry.d.ts +4 -0
- package/dist/src/agent/subagent-registry.js +1 -1
- package/dist/src/friday-session.js +35 -1
- package/dist/src/http/handlers/agents-list.js +5 -1
- package/dist/src/skills-discovery.d.ts +11 -4
- package/dist/src/skills-discovery.js +55 -12
- package/package.json +14 -15
- package/src/agent/subagent-registry.ts +3 -1
- package/src/e2e/subagent.e2e.test.ts +6 -0
- package/src/friday-session.forward-agent.test.ts +127 -0
- package/src/friday-session.ts +48 -1
- package/src/http/handlers/agents-list.test.ts +28 -0
- package/src/http/handlers/agents-list.ts +5 -1
- package/src/skills-discovery.test.ts +50 -1
- package/src/skills-discovery.ts +52 -12
|
@@ -25,6 +25,10 @@ type SpawnIntent = {
|
|
|
25
25
|
};
|
|
26
26
|
/** Called by forwardAgentEventRaw on lifecycle start so we can resolve parentRunId. */
|
|
27
27
|
export declare function registerSessionKeyForRun(sessionKey: string, runId: string): void;
|
|
28
|
+
export declare function parseAnnounceRunId(runId: string): {
|
|
29
|
+
childSessionKey: string;
|
|
30
|
+
bareRunId: string;
|
|
31
|
+
} | null;
|
|
28
32
|
/** Look up subagent entry by any runId form (announce compound or bare). */
|
|
29
33
|
export declare function lookupByRunId(runId: string): SubagentEntry | undefined;
|
|
30
34
|
/** Look up subagent entry by childSessionKey. */
|
|
@@ -16,7 +16,7 @@ export function registerSessionKeyForRun(sessionKey, runId) {
|
|
|
16
16
|
* → { childSessionKey: "agent:main:subagent:uuid", bareRunId: "runId" }
|
|
17
17
|
*/
|
|
18
18
|
const ANNOUNCE_RUN_ID_RE = /^announce:v\d+:(agent:.+?):([^:]+)$/;
|
|
19
|
-
function parseAnnounceRunId(runId) {
|
|
19
|
+
export function parseAnnounceRunId(runId) {
|
|
20
20
|
const m = runId.match(ANNOUNCE_RUN_ID_RE);
|
|
21
21
|
if (!m)
|
|
22
22
|
return null;
|
|
@@ -6,7 +6,7 @@ import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
|
|
|
6
6
|
import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
|
|
7
7
|
import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
|
|
8
8
|
import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
|
|
9
|
-
import { lookupByRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
|
|
9
|
+
import { lookupByRunId, lookupByChildSessionKey, parseAnnounceRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
|
|
10
10
|
/** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
|
|
11
11
|
const lastThinkingTextByRun = new Map();
|
|
12
12
|
function commonPrefixLength(a, b) {
|
|
@@ -323,6 +323,10 @@ export function forwardAgentEventRaw(evt) {
|
|
|
323
323
|
phase: "spawning",
|
|
324
324
|
childSessionKey: null,
|
|
325
325
|
runId: null,
|
|
326
|
+
// A2: the spawn tool-call id is the only stable correlation key before the
|
|
327
|
+
// gateway assigns childSessionKey/runId — the app mints the placeholder window
|
|
328
|
+
// under it, then rekeys to childSessionKey on spawned.
|
|
329
|
+
toolCallId,
|
|
326
330
|
label: intent.label ?? null,
|
|
327
331
|
parentRunId: intent.parentRunId,
|
|
328
332
|
depth: intent.depth,
|
|
@@ -357,6 +361,9 @@ export function forwardAgentEventRaw(evt) {
|
|
|
357
361
|
phase: "spawned",
|
|
358
362
|
runId: compoundRunId,
|
|
359
363
|
childSessionKey: entry.childSessionKey,
|
|
364
|
+
// A2: echo the spawn toolCallId so the app deterministically links this
|
|
365
|
+
// spawned event to the placeholder window it minted at spawning time.
|
|
366
|
+
toolCallId: toolCallId || null,
|
|
360
367
|
label: entry.label ?? null,
|
|
361
368
|
parentRunId: entry.parentRunId ?? null,
|
|
362
369
|
depth: entry.depth,
|
|
@@ -365,6 +372,28 @@ export function forwardAgentEventRaw(evt) {
|
|
|
365
372
|
}, entry.deviceId);
|
|
366
373
|
}
|
|
367
374
|
}
|
|
375
|
+
// Phase 3 (A3): announce-summary delivery to the parent. OpenClaw emits the parent's
|
|
376
|
+
// `lifecycle.start` under the announce compound runId once a subagent's result is being
|
|
377
|
+
// folded back in. We parse the authoritative childSessionKey here (the registry already
|
|
378
|
+
// knows how) and broadcast an explicit `dismissed` subagent event, so the app removes the
|
|
379
|
+
// settled window by childSessionKey instead of re-parsing the announce runId itself.
|
|
380
|
+
if (evt.stream === "lifecycle" && evt.data.phase === "start") {
|
|
381
|
+
const announced = parseAnnounceRunId(evt.runId);
|
|
382
|
+
if (announced) {
|
|
383
|
+
const entry = lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
|
|
384
|
+
sseEmitter.broadcast({
|
|
385
|
+
type: "subagent",
|
|
386
|
+
data: {
|
|
387
|
+
phase: "dismissed",
|
|
388
|
+
childSessionKey: entry?.childSessionKey ?? announced.childSessionKey,
|
|
389
|
+
runId: entry?.runId ?? announced.bareRunId ?? null,
|
|
390
|
+
parentRunId: entry?.parentRunId ?? null,
|
|
391
|
+
depth: entry?.depth ?? 1,
|
|
392
|
+
deviceId: deviceIdRaw,
|
|
393
|
+
},
|
|
394
|
+
}, deviceIdRaw);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
368
397
|
const subagentEntry = lookupByRunId(evt.runId);
|
|
369
398
|
// Only annotate events that originate from the subagent itself
|
|
370
399
|
// (sessionKey matches childSessionKey). Main-agent delivery events
|
|
@@ -375,6 +404,11 @@ export function forwardAgentEventRaw(evt) {
|
|
|
375
404
|
label: subagentEntry.label,
|
|
376
405
|
parentRunId: subagentEntry.parentRunId,
|
|
377
406
|
depth: subagentEntry.depth,
|
|
407
|
+
// A1: ship the authoritative childSessionKey + lifecycle status on every
|
|
408
|
+
// subagent agent-delta so the app routes/identifies by stable keys instead of
|
|
409
|
+
// guessing from runId.
|
|
410
|
+
childSessionKey: subagentEntry.childSessionKey,
|
|
411
|
+
status: subagentEntry.status,
|
|
378
412
|
}
|
|
379
413
|
: undefined;
|
|
380
414
|
let outgoingData = { ...evt.data };
|
|
@@ -82,8 +82,12 @@ function resolveConfiguredAgents() {
|
|
|
82
82
|
const agents = cfg?.agents;
|
|
83
83
|
const list = agents?.list;
|
|
84
84
|
if (!Array.isArray(list) || list.length === 0) {
|
|
85
|
+
// Implicit `main` agent (no `agents.list`): config carries no name, so fall
|
|
86
|
+
// back to the workspace IDENTITY.md `Name` — the same source ControlUI and
|
|
87
|
+
// the list branch below use — instead of letting the app show the raw id.
|
|
88
|
+
const name = readWorkspaceIdentityName(rt, cfg, DEFAULT_AGENT_ID);
|
|
85
89
|
return {
|
|
86
|
-
agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
|
|
90
|
+
agents: [{ id: DEFAULT_AGENT_ID, isDefault: true, ...(name ? { name } : {}) }],
|
|
87
91
|
defaultAgentId: DEFAULT_AGENT_ID,
|
|
88
92
|
};
|
|
89
93
|
}
|
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -36,6 +43,7 @@
|
|
|
36
43
|
* `skills[]` config, already returned separately by the config view.
|
|
37
44
|
*/
|
|
38
45
|
import fs from "node:fs";
|
|
46
|
+
import os from "node:os";
|
|
39
47
|
import path from "node:path";
|
|
40
48
|
import { createRequire } from "node:module";
|
|
41
49
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
@@ -100,9 +108,24 @@ function collectSkills(root, source, out, depth = 0) {
|
|
|
100
108
|
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
101
109
|
}
|
|
102
110
|
for (const e of entries) {
|
|
103
|
-
if (e.
|
|
104
|
-
|
|
111
|
+
if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name))
|
|
112
|
+
continue;
|
|
113
|
+
const full = path.join(root, e.name);
|
|
114
|
+
// Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
|
|
115
|
+
// `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
|
|
116
|
+
// skills as symlinks under `<configDir>/plugin-skills/` — so without this the
|
|
117
|
+
// entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
|
|
118
|
+
let isDir = e.isDirectory();
|
|
119
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
120
|
+
try {
|
|
121
|
+
isDir = fs.statSync(full).isDirectory();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
isDir = false; // broken/unreadable symlink
|
|
125
|
+
}
|
|
105
126
|
}
|
|
127
|
+
if (isDir)
|
|
128
|
+
collectSkills(full, source, out, depth + 1);
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
let cachedOpenClawRoot;
|
|
@@ -216,24 +239,44 @@ export function discoverAvailableSkills(cfg, agentId) {
|
|
|
216
239
|
// leaked main's skills into every other agent's catalog.
|
|
217
240
|
try {
|
|
218
241
|
const ws = resolveWs(cfg, agentId);
|
|
219
|
-
if (ws)
|
|
242
|
+
if (ws) {
|
|
220
243
|
sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
244
|
+
// Project-level agents skills: `<workspace>/.agents/skills` (core source
|
|
245
|
+
// `agents-skills-project`), scoped to this same target agent's workspace.
|
|
246
|
+
sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
|
|
247
|
+
}
|
|
221
248
|
}
|
|
222
249
|
catch {
|
|
223
250
|
// skip unresolvable workspace
|
|
224
251
|
}
|
|
225
|
-
// Managed
|
|
226
|
-
// DEFAULT agent's workspace parent (the default workspace
|
|
227
|
-
// whereas non-default workspaces may be nested under it).
|
|
252
|
+
// Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
|
|
253
|
+
// agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
|
|
254
|
+
// lives directly under configDir, whereas non-default workspaces may be nested under it).
|
|
228
255
|
try {
|
|
229
256
|
const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
|
|
230
|
-
if (defaultWs)
|
|
231
|
-
|
|
257
|
+
if (defaultWs) {
|
|
258
|
+
const configDir = path.dirname(defaultWs);
|
|
259
|
+
sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
|
|
260
|
+
// OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
|
|
261
|
+
// `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
|
|
262
|
+
// and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
|
|
263
|
+
// extensions, so plugin-provided skills were missing entirely.
|
|
264
|
+
sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
|
|
265
|
+
}
|
|
232
266
|
}
|
|
233
267
|
catch {
|
|
234
268
|
// skip unresolvable managed dir
|
|
235
269
|
}
|
|
236
270
|
}
|
|
271
|
+
// Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
|
|
272
|
+
try {
|
|
273
|
+
const home = os.homedir();
|
|
274
|
+
if (home)
|
|
275
|
+
sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// home dir unresolvable on this platform — skip
|
|
279
|
+
}
|
|
237
280
|
const extraDirs = c?.skills?.load?.extraDirs;
|
|
238
281
|
if (Array.isArray(extraDirs)) {
|
|
239
282
|
for (const d of extraDirs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.40",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,19 +12,6 @@
|
|
|
12
12
|
"tsconfig.json",
|
|
13
13
|
"openclaw.plugin.json"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.json",
|
|
17
|
-
"lint": "eslint .",
|
|
18
|
-
"lint:fix": "eslint . --fix",
|
|
19
|
-
"format": "prettier --write .",
|
|
20
|
-
"format:check": "prettier --check .",
|
|
21
|
-
"prepublishOnly": "pnpm build && rm -rf dist/attachments",
|
|
22
|
-
"test": "npm run test:unit && npm run test:e2e",
|
|
23
|
-
"test:unit": "vitest run",
|
|
24
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
25
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
26
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
27
|
-
},
|
|
28
15
|
"bin": {
|
|
29
16
|
"friday-channel-next": "install.js"
|
|
30
17
|
},
|
|
@@ -75,5 +62,17 @@
|
|
|
75
62
|
"typescript-eslint": "^8.61.1",
|
|
76
63
|
"vitest": "^4.1.5",
|
|
77
64
|
"zod": "^4.3.6"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.json",
|
|
68
|
+
"lint": "eslint .",
|
|
69
|
+
"lint:fix": "eslint . --fix",
|
|
70
|
+
"format": "prettier --write .",
|
|
71
|
+
"format:check": "prettier --check .",
|
|
72
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
73
|
+
"test:unit": "vitest run",
|
|
74
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
75
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
76
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
78
77
|
}
|
|
79
|
-
}
|
|
78
|
+
}
|
|
@@ -48,7 +48,9 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
|
|
|
48
48
|
*/
|
|
49
49
|
const ANNOUNCE_RUN_ID_RE = /^announce:v\d+:(agent:.+?):([^:]+)$/;
|
|
50
50
|
|
|
51
|
-
function parseAnnounceRunId(
|
|
51
|
+
export function parseAnnounceRunId(
|
|
52
|
+
runId: string,
|
|
53
|
+
): { childSessionKey: string; bareRunId: string } | null {
|
|
52
54
|
const m = runId.match(ANNOUNCE_RUN_ID_RE);
|
|
53
55
|
if (!m) return null;
|
|
54
56
|
return { childSessionKey: m[1] ?? "", bareRunId: m[2] ?? "" };
|
|
@@ -295,6 +295,9 @@ describe("subagent via sessions_spawn tool", () => {
|
|
|
295
295
|
label: "cr",
|
|
296
296
|
parentRunId: mainRunId,
|
|
297
297
|
depth: 1,
|
|
298
|
+
// A1: annotation now ships stable identity (childSessionKey) + lifecycle status.
|
|
299
|
+
childSessionKey: childKey,
|
|
300
|
+
status: "running",
|
|
298
301
|
});
|
|
299
302
|
});
|
|
300
303
|
|
|
@@ -524,6 +527,9 @@ describe("subagent via sessions_spawn tool", () => {
|
|
|
524
527
|
label: "reviewer",
|
|
525
528
|
parentRunId: mainRunId,
|
|
526
529
|
depth: 1,
|
|
530
|
+
// A1: annotation now ships stable identity (childSessionKey) + lifecycle status.
|
|
531
|
+
childSessionKey: childKeyA,
|
|
532
|
+
status: "running",
|
|
527
533
|
});
|
|
528
534
|
|
|
529
535
|
// sessions_spawn for B (nested from A's tool call — but parentRunId should come from the context)
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
} from "./agent/run-usage-accumulator.js";
|
|
17
17
|
import { sseEmitter } from "./sse/emitter.js";
|
|
18
18
|
import { toSessionStoreKey } from "./session/session-manager.js";
|
|
19
|
+
import {
|
|
20
|
+
ensureSubagentFromSpawnTool,
|
|
21
|
+
resetForTest as resetSubagentRegistryForTest,
|
|
22
|
+
} from "./agent/subagent-registry.js";
|
|
19
23
|
|
|
20
24
|
describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
21
25
|
const runId = "run-thinking-test";
|
|
@@ -395,3 +399,126 @@ function commonPrefixLen(a: string, b: string): number {
|
|
|
395
399
|
while (i < len && a.charCodeAt(i) === b.charCodeAt(i)) i++;
|
|
396
400
|
return i;
|
|
397
401
|
}
|
|
402
|
+
|
|
403
|
+
// P1 of the subagent streaming redo (see subagent-streaming-redo-plan.md):
|
|
404
|
+
// the plugin ships authoritative correlation keys so the app stops self-deriving identity.
|
|
405
|
+
describe("forwardAgentEventRaw (subagent stable-identity fields: A1/A2/A3)", () => {
|
|
406
|
+
const sessionKey = "agent:main:friday-session-test";
|
|
407
|
+
const deviceId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
|
|
408
|
+
const childKey = "agent:main:subagent:abc";
|
|
409
|
+
|
|
410
|
+
type SubagentBroadcast = { type: string; data: Record<string, unknown> };
|
|
411
|
+
|
|
412
|
+
function subagentBroadcasts(phase: string): Record<string, unknown>[] {
|
|
413
|
+
return (sseEmitter.broadcast as ReturnType<typeof vi.fn>).mock.calls
|
|
414
|
+
.map((c) => c[0] as SubagentBroadcast)
|
|
415
|
+
.filter((m) => m.type === "subagent" && m.data.phase === phase)
|
|
416
|
+
.map((m) => m.data);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
beforeEach(() => {
|
|
420
|
+
sseEmitter.resetForTest();
|
|
421
|
+
resetThinkingStreamAccumStateForTest();
|
|
422
|
+
resetOpenClawRunDeviceMappingForTest();
|
|
423
|
+
resetSubagentRegistryForTest();
|
|
424
|
+
registerFridaySessionDeviceMapping(sessionKey, deviceId);
|
|
425
|
+
vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation(() => {});
|
|
426
|
+
vi.spyOn(sseEmitter, "broadcast").mockImplementation(() => {});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(() => {
|
|
430
|
+
vi.restoreAllMocks();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// A2: spawning carries the spawn tool-call id as a stable correlation key.
|
|
434
|
+
it("spawning event carries the spawn toolCallId", () => {
|
|
435
|
+
forwardAgentEventRaw({
|
|
436
|
+
runId: "parent-run",
|
|
437
|
+
seq: 1,
|
|
438
|
+
stream: "tool",
|
|
439
|
+
sessionKey,
|
|
440
|
+
data: {
|
|
441
|
+
name: "sessions_spawn",
|
|
442
|
+
phase: "start",
|
|
443
|
+
toolCallId: "tc-1",
|
|
444
|
+
args: { taskName: "weather" },
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const spawning = subagentBroadcasts("spawning");
|
|
449
|
+
expect(spawning).toHaveLength(1);
|
|
450
|
+
expect(spawning[0].toolCallId).toBe("tc-1");
|
|
451
|
+
expect(spawning[0].childSessionKey).toBeNull();
|
|
452
|
+
expect(spawning[0].runId).toBeNull();
|
|
453
|
+
expect(spawning[0].label).toBe("weather");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// A1: a subagent's own agent-delta is annotated with childSessionKey + status.
|
|
457
|
+
it("subagent agent-delta annotation includes childSessionKey and status", () => {
|
|
458
|
+
ensureSubagentFromSpawnTool({
|
|
459
|
+
childSessionKey: childKey,
|
|
460
|
+
bareRunId: "sub-bare",
|
|
461
|
+
label: "weather",
|
|
462
|
+
deviceId,
|
|
463
|
+
parentRunId: "parent-run",
|
|
464
|
+
requesterSessionKey: sessionKey,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
forwardAgentEventRaw({
|
|
468
|
+
runId: "sub-bare",
|
|
469
|
+
seq: 1,
|
|
470
|
+
stream: "thinking",
|
|
471
|
+
sessionKey: childKey, // subagent's own event → sessionKey === childSessionKey
|
|
472
|
+
data: { text: "looking up", delta: "looking up" },
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
476
|
+
const payload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
477
|
+
const subagent = payload.subagent as Record<string, unknown>;
|
|
478
|
+
expect(subagent).toBeDefined();
|
|
479
|
+
expect(subagent.childSessionKey).toBe(childKey);
|
|
480
|
+
expect(subagent.status).toBe("running");
|
|
481
|
+
expect(subagent.label).toBe("weather");
|
|
482
|
+
expect(subagent.parentRunId).toBe("parent-run");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// A3: the parent's announce-summary lifecycle.start emits an explicit dismiss keyed by
|
|
486
|
+
// childSessionKey — replacing the app's fragile announce-runId string parsing.
|
|
487
|
+
it("announce-summary lifecycle.start emits a dismissed subagent event by childSessionKey", () => {
|
|
488
|
+
ensureSubagentFromSpawnTool({
|
|
489
|
+
childSessionKey: childKey,
|
|
490
|
+
bareRunId: "sub-bare",
|
|
491
|
+
label: "weather",
|
|
492
|
+
deviceId,
|
|
493
|
+
parentRunId: "parent-run",
|
|
494
|
+
requesterSessionKey: sessionKey,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const announceRunId = `announce:v1:${childKey}:sub-bare`;
|
|
498
|
+
forwardAgentEventRaw({
|
|
499
|
+
runId: announceRunId,
|
|
500
|
+
seq: 1,
|
|
501
|
+
stream: "lifecycle",
|
|
502
|
+
sessionKey, // parent's sessionKey, not the child's
|
|
503
|
+
data: { phase: "start" },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const dismissed = subagentBroadcasts("dismissed");
|
|
507
|
+
expect(dismissed).toHaveLength(1);
|
|
508
|
+
expect(dismissed[0].childSessionKey).toBe(childKey);
|
|
509
|
+
expect(dismissed[0].runId).toBe("sub-bare");
|
|
510
|
+
expect(dismissed[0].parentRunId).toBe("parent-run");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// A3 must not fire on ordinary (non-announce) lifecycle.start frames.
|
|
514
|
+
it("does not emit dismissed for a normal lifecycle.start", () => {
|
|
515
|
+
forwardAgentEventRaw({
|
|
516
|
+
runId: "parent-run",
|
|
517
|
+
seq: 1,
|
|
518
|
+
stream: "lifecycle",
|
|
519
|
+
sessionKey,
|
|
520
|
+
data: { phase: "start" },
|
|
521
|
+
});
|
|
522
|
+
expect(subagentBroadcasts("dismissed")).toHaveLength(0);
|
|
523
|
+
});
|
|
524
|
+
});
|
package/src/friday-session.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
|
|
|
9
9
|
import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
|
|
10
10
|
import {
|
|
11
11
|
lookupByRunId,
|
|
12
|
+
lookupByChildSessionKey,
|
|
13
|
+
parseAnnounceRunId,
|
|
12
14
|
registerSessionKeyForRun,
|
|
13
15
|
registerSpawnIntent,
|
|
14
16
|
consumeSpawnIntent,
|
|
@@ -234,7 +236,13 @@ function completeAgentEventForward(params: {
|
|
|
234
236
|
deviceIdRaw: string;
|
|
235
237
|
outgoingData: Record<string, unknown>;
|
|
236
238
|
isTerminalLifecycle: boolean;
|
|
237
|
-
subagentMeta?: {
|
|
239
|
+
subagentMeta?: {
|
|
240
|
+
label?: string;
|
|
241
|
+
parentRunId?: string;
|
|
242
|
+
depth: number;
|
|
243
|
+
childSessionKey?: string;
|
|
244
|
+
status?: string;
|
|
245
|
+
};
|
|
238
246
|
}): void {
|
|
239
247
|
const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle, subagentMeta } = params;
|
|
240
248
|
|
|
@@ -367,6 +375,10 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
367
375
|
phase: "spawning",
|
|
368
376
|
childSessionKey: null,
|
|
369
377
|
runId: null,
|
|
378
|
+
// A2: the spawn tool-call id is the only stable correlation key before the
|
|
379
|
+
// gateway assigns childSessionKey/runId — the app mints the placeholder window
|
|
380
|
+
// under it, then rekeys to childSessionKey on spawned.
|
|
381
|
+
toolCallId,
|
|
370
382
|
label: intent.label ?? null,
|
|
371
383
|
parentRunId: intent.parentRunId,
|
|
372
384
|
depth: intent.depth,
|
|
@@ -408,6 +420,9 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
408
420
|
phase: "spawned",
|
|
409
421
|
runId: compoundRunId,
|
|
410
422
|
childSessionKey: entry.childSessionKey,
|
|
423
|
+
// A2: echo the spawn toolCallId so the app deterministically links this
|
|
424
|
+
// spawned event to the placeholder window it minted at spawning time.
|
|
425
|
+
toolCallId: toolCallId || null,
|
|
411
426
|
label: entry.label ?? null,
|
|
412
427
|
parentRunId: entry.parentRunId ?? null,
|
|
413
428
|
depth: entry.depth,
|
|
@@ -419,6 +434,33 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
419
434
|
}
|
|
420
435
|
}
|
|
421
436
|
|
|
437
|
+
// Phase 3 (A3): announce-summary delivery to the parent. OpenClaw emits the parent's
|
|
438
|
+
// `lifecycle.start` under the announce compound runId once a subagent's result is being
|
|
439
|
+
// folded back in. We parse the authoritative childSessionKey here (the registry already
|
|
440
|
+
// knows how) and broadcast an explicit `dismissed` subagent event, so the app removes the
|
|
441
|
+
// settled window by childSessionKey instead of re-parsing the announce runId itself.
|
|
442
|
+
if (evt.stream === "lifecycle" && evt.data.phase === "start") {
|
|
443
|
+
const announced = parseAnnounceRunId(evt.runId);
|
|
444
|
+
if (announced) {
|
|
445
|
+
const entry =
|
|
446
|
+
lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
|
|
447
|
+
sseEmitter.broadcast(
|
|
448
|
+
{
|
|
449
|
+
type: "subagent",
|
|
450
|
+
data: {
|
|
451
|
+
phase: "dismissed",
|
|
452
|
+
childSessionKey: entry?.childSessionKey ?? announced.childSessionKey,
|
|
453
|
+
runId: entry?.runId ?? announced.bareRunId ?? null,
|
|
454
|
+
parentRunId: entry?.parentRunId ?? null,
|
|
455
|
+
depth: entry?.depth ?? 1,
|
|
456
|
+
deviceId: deviceIdRaw,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
deviceIdRaw,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
422
464
|
const subagentEntry = lookupByRunId(evt.runId);
|
|
423
465
|
// Only annotate events that originate from the subagent itself
|
|
424
466
|
// (sessionKey matches childSessionKey). Main-agent delivery events
|
|
@@ -429,6 +471,11 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
429
471
|
label: subagentEntry.label,
|
|
430
472
|
parentRunId: subagentEntry.parentRunId,
|
|
431
473
|
depth: subagentEntry.depth,
|
|
474
|
+
// A1: ship the authoritative childSessionKey + lifecycle status on every
|
|
475
|
+
// subagent agent-delta so the app routes/identifies by stable keys instead of
|
|
476
|
+
// guessing from runId.
|
|
477
|
+
childSessionKey: subagentEntry.childSessionKey,
|
|
478
|
+
status: subagentEntry.status,
|
|
432
479
|
}
|
|
433
480
|
: undefined;
|
|
434
481
|
|
|
@@ -71,6 +71,34 @@ describe("handleAgentsList", () => {
|
|
|
71
71
|
expect(body.agents).toEqual([{ id: "main", isDefault: true }]);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it("resolves the implicit main name from IDENTITY.md when no agents.list exists", async () => {
|
|
75
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-identity-main-"));
|
|
76
|
+
fs.writeFileSync(
|
|
77
|
+
path.join(workspace, "IDENTITY.md"),
|
|
78
|
+
"# IDENTITY.md\n\n- **Name:** F.R.I.D.A.Y\n- **Emoji:** 🌿\n",
|
|
79
|
+
);
|
|
80
|
+
try {
|
|
81
|
+
setFridayAgentForwardRuntime({
|
|
82
|
+
runtime: {
|
|
83
|
+
agent: {
|
|
84
|
+
session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
|
|
85
|
+
resolveAgentWorkspaceDir: () => workspace,
|
|
86
|
+
},
|
|
87
|
+
config: { current: () => ({ agents: { defaults: {} } }) },
|
|
88
|
+
},
|
|
89
|
+
} as any);
|
|
90
|
+
|
|
91
|
+
const res = new MockRes();
|
|
92
|
+
await handleAgentsList(makeReq(AUTH), res as any);
|
|
93
|
+
|
|
94
|
+
const body = JSON.parse(res.body);
|
|
95
|
+
expect(body.defaultAgentId).toBe("main");
|
|
96
|
+
expect(body.agents).toEqual([{ id: "main", name: "F.R.I.D.A.Y", isDefault: true }]);
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
74
102
|
it("lists configured agents with normalized ids and resolved fields", async () => {
|
|
75
103
|
setConfig({
|
|
76
104
|
agents: {
|
|
@@ -106,8 +106,12 @@ function resolveConfiguredAgents(): ResolvedAgents {
|
|
|
106
106
|
const list = agents?.list as Array<Record<string, unknown>> | undefined;
|
|
107
107
|
|
|
108
108
|
if (!Array.isArray(list) || list.length === 0) {
|
|
109
|
+
// Implicit `main` agent (no `agents.list`): config carries no name, so fall
|
|
110
|
+
// back to the workspace IDENTITY.md `Name` — the same source ControlUI and
|
|
111
|
+
// the list branch below use — instead of letting the app show the raw id.
|
|
112
|
+
const name = readWorkspaceIdentityName(rt, cfg, DEFAULT_AGENT_ID);
|
|
109
113
|
return {
|
|
110
|
-
agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
|
|
114
|
+
agents: [{ id: DEFAULT_AGENT_ID, isDefault: true, ...(name ? { name } : {}) }],
|
|
111
115
|
defaultAgentId: DEFAULT_AGENT_ID,
|
|
112
116
|
};
|
|
113
117
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -22,6 +22,8 @@ function makeSkills(parent: string, ids: string[]): void {
|
|
|
22
22
|
|
|
23
23
|
describe("discoverAvailableSkills", () => {
|
|
24
24
|
let root: string;
|
|
25
|
+
let emptyHome: string;
|
|
26
|
+
let savedHome: string | undefined;
|
|
25
27
|
|
|
26
28
|
function wire(configRoot: string, cfg: unknown): void {
|
|
27
29
|
setFridayAgentForwardRuntime({
|
|
@@ -39,10 +41,21 @@ describe("discoverAvailableSkills", () => {
|
|
|
39
41
|
} as never);
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
// Isolate `~/.agents/skills` (a real discovery source) from the dev machine's
|
|
46
|
+
// home so exact-match assertions don't pick up the operator's actual skills.
|
|
47
|
+
savedHome = process.env.HOME;
|
|
48
|
+
emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "friday-home-"));
|
|
49
|
+
process.env.HOME = emptyHome;
|
|
50
|
+
});
|
|
51
|
+
|
|
42
52
|
afterEach(() => {
|
|
53
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
54
|
+
else process.env.HOME = savedHome;
|
|
43
55
|
resetFridayAgentForwardRuntimeForTest();
|
|
44
56
|
resetOpenClawRootCacheForTest();
|
|
45
57
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
58
|
+
if (emptyHome) fs.rmSync(emptyHome, { recursive: true, force: true });
|
|
46
59
|
});
|
|
47
60
|
|
|
48
61
|
it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
|
|
@@ -118,6 +131,42 @@ describe("discoverAvailableSkills", () => {
|
|
|
118
131
|
expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
|
|
119
132
|
});
|
|
120
133
|
|
|
134
|
+
it("discovers plugin-skills (symlinks), plus project + personal agents skills as 'extra'", () => {
|
|
135
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
|
|
136
|
+
const configRoot = path.join(root, "configdir");
|
|
137
|
+
const operatorWs = path.join(configRoot, "workspace", "agents", "operator");
|
|
138
|
+
|
|
139
|
+
// target agent's own workspace skills
|
|
140
|
+
makeSkills(path.join(operatorWs, "skills"), ["own-skill"]);
|
|
141
|
+
// project-level agents skills: <workspace>/.agents/skills
|
|
142
|
+
makeSkills(path.join(operatorWs, ".agents", "skills"), ["project-skill"]);
|
|
143
|
+
// personal agents skills: <home>/.agents/skills (HOME isolated in beforeEach)
|
|
144
|
+
makeSkills(path.join(emptyHome, ".agents", "skills"), ["personal-skill"]);
|
|
145
|
+
|
|
146
|
+
// plugin-skills: real plugin skill dirs SYMLINKED into <configDir>/plugin-skills/,
|
|
147
|
+
// exactly how OpenClaw publishes third-party plugin skills (e.g. the miloco-* set).
|
|
148
|
+
const pluginPkg = path.join(root, "miloco-openclaw-plugin", "skills");
|
|
149
|
+
makeSkills(pluginPkg, ["miloco-devices", "miloco-notify"]);
|
|
150
|
+
const pluginSkillsDir = path.join(configRoot, "plugin-skills");
|
|
151
|
+
fs.mkdirSync(pluginSkillsDir, { recursive: true });
|
|
152
|
+
for (const id of ["miloco-devices", "miloco-notify"]) {
|
|
153
|
+
fs.symlinkSync(path.join(pluginPkg, id), path.join(pluginSkillsDir, id), "dir");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cfg = { agents: { list: [{ id: "main", default: true }, { id: "operator" }] } };
|
|
157
|
+
wire(configRoot, cfg);
|
|
158
|
+
|
|
159
|
+
const result = discoverAvailableSkills(cfg, "operator");
|
|
160
|
+
const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
|
|
161
|
+
expect(bySource).toEqual({
|
|
162
|
+
"own-skill": "workspace",
|
|
163
|
+
"project-skill": "extra",
|
|
164
|
+
"personal-skill": "extra",
|
|
165
|
+
"miloco-devices": "extra", // followed through the symlink — the miloco regression
|
|
166
|
+
"miloco-notify": "extra",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
121
170
|
it("returns [] without throwing when nothing is resolvable", () => {
|
|
122
171
|
resetFridayAgentForwardRuntimeForTest();
|
|
123
172
|
expect(discoverAvailableSkills({}, "main")).toEqual([]);
|
package/src/skills-discovery.ts
CHANGED
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
* in another agent's workspace (main is just another agent, not a shared pool)
|
|
18
18
|
* - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
|
|
19
19
|
* - built-in : bundled core skills (`<openclaw>/skills`)
|
|
20
|
-
* - extra :
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - extra : everything ControlUI buckets as "EXTRA" —
|
|
21
|
+
* • `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
|
|
22
|
+
* plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
|
|
23
|
+
* • `<workspace>/.agents/skills` : project-level agents skills (target agent)
|
|
24
|
+
* • `~/.agents/skills` : personal agents skills
|
|
25
|
+
* • config `skills.load.extraDirs`
|
|
26
|
+
* • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
|
|
27
|
+
* gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
|
|
28
|
+
* plugin-skills, kept as a belt-and-suspenders fallback
|
|
24
29
|
*
|
|
25
30
|
* Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
|
|
31
|
+
* The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
|
|
32
|
+
* this the entire plugin-provided set is invisible.
|
|
26
33
|
*
|
|
27
34
|
* A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
|
|
28
35
|
* the containing dir name) — NOT the dir name itself, which often differs (e.g. the
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
*/
|
|
38
45
|
|
|
39
46
|
import fs from "node:fs";
|
|
47
|
+
import os from "node:os";
|
|
40
48
|
import path from "node:path";
|
|
41
49
|
import { createRequire } from "node:module";
|
|
42
50
|
import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
|
|
@@ -107,9 +115,21 @@ function collectSkills(
|
|
|
107
115
|
return; // a skill is a leaf — don't treat its internals as nested skills
|
|
108
116
|
}
|
|
109
117
|
for (const e of entries) {
|
|
110
|
-
if (e.
|
|
111
|
-
|
|
118
|
+
if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name)) continue;
|
|
119
|
+
const full = path.join(root, e.name);
|
|
120
|
+
// Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
|
|
121
|
+
// `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
|
|
122
|
+
// skills as symlinks under `<configDir>/plugin-skills/` — so without this the
|
|
123
|
+
// entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
|
|
124
|
+
let isDir = e.isDirectory();
|
|
125
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
126
|
+
try {
|
|
127
|
+
isDir = fs.statSync(full).isDirectory();
|
|
128
|
+
} catch {
|
|
129
|
+
isDir = false; // broken/unreadable symlink
|
|
130
|
+
}
|
|
112
131
|
}
|
|
132
|
+
if (isDir) collectSkills(full, source, out, depth + 1);
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
|
|
@@ -227,22 +247,42 @@ export function discoverAvailableSkills(cfg: unknown, agentId: string): Discover
|
|
|
227
247
|
// leaked main's skills into every other agent's catalog.
|
|
228
248
|
try {
|
|
229
249
|
const ws = resolveWs(cfg, agentId);
|
|
230
|
-
if (ws)
|
|
250
|
+
if (ws) {
|
|
251
|
+
sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
|
|
252
|
+
// Project-level agents skills: `<workspace>/.agents/skills` (core source
|
|
253
|
+
// `agents-skills-project`), scoped to this same target agent's workspace.
|
|
254
|
+
sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
|
|
255
|
+
}
|
|
231
256
|
} catch {
|
|
232
257
|
// skip unresolvable workspace
|
|
233
258
|
}
|
|
234
|
-
// Managed
|
|
235
|
-
// DEFAULT agent's workspace parent (the default workspace
|
|
236
|
-
// whereas non-default workspaces may be nested under it).
|
|
259
|
+
// Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
|
|
260
|
+
// agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
|
|
261
|
+
// lives directly under configDir, whereas non-default workspaces may be nested under it).
|
|
237
262
|
try {
|
|
238
263
|
const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
|
|
239
|
-
if (defaultWs)
|
|
240
|
-
|
|
264
|
+
if (defaultWs) {
|
|
265
|
+
const configDir = path.dirname(defaultWs);
|
|
266
|
+
sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
|
|
267
|
+
// OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
|
|
268
|
+
// `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
|
|
269
|
+
// and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
|
|
270
|
+
// extensions, so plugin-provided skills were missing entirely.
|
|
271
|
+
sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
|
|
272
|
+
}
|
|
241
273
|
} catch {
|
|
242
274
|
// skip unresolvable managed dir
|
|
243
275
|
}
|
|
244
276
|
}
|
|
245
277
|
|
|
278
|
+
// Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
|
|
279
|
+
try {
|
|
280
|
+
const home = os.homedir();
|
|
281
|
+
if (home) sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
|
|
282
|
+
} catch {
|
|
283
|
+
// home dir unresolvable on this platform — skip
|
|
284
|
+
}
|
|
285
|
+
|
|
246
286
|
const extraDirs = (
|
|
247
287
|
(c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
|
|
248
288
|
)?.extraDirs;
|