@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.
@@ -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 : 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"`).
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 : 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"`).
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.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
104
- collectSkills(path.join(root, e.name), source, out, depth + 1);
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 skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
226
- // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
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
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
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.38",
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(runId: string): { childSessionKey: string; bareRunId: string } | null {
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
+ });
@@ -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?: { label?: string; parentRunId?: string; depth: number };
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([]);
@@ -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 : 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"`).
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.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
111
- collectSkills(path.join(root, e.name), source, out, depth + 1);
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) sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
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 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).
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
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
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;