@hienlh/ppm 0.17.47 → 0.17.49

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 (49) hide show
  1. package/CHANGELOG.md +3662 -3649
  2. package/CLAUDE.md +1 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/dist/web/assets/{ai-resource-editor-BywFZ8kq.js → ai-resource-editor-x8J0omsj.js} +1 -1
  6. package/dist/web/assets/{audio-preview-0l5TJLgd.js → audio-preview-Bdf_SZHY.js} +1 -1
  7. package/dist/web/assets/chat-tab-CT6SP5D3.js +13 -0
  8. package/dist/web/assets/{code-editor-BfuUP1De.js → code-editor-BgsJkCtK.js} +2 -2
  9. package/dist/web/assets/{conflict-editor-B47yD0OS.js → conflict-editor-DdSe9RCG.js} +1 -1
  10. package/dist/web/assets/{csv-preview-C7xm3SBD.js → csv-preview-DrUfLPRE.js} +1 -1
  11. package/dist/web/assets/{database-viewer-C7IEdTpP.js → database-viewer-1NLuRknj.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DRAU_SI7.js → diff-viewer-DPn8QJXG.js} +1 -1
  13. package/dist/web/assets/{docx-preview-BK7hQo6W.js → docx-preview-BUVtMDQF.js} +1 -1
  14. package/dist/web/assets/{extension-webview-JK1Z7erV.js → extension-webview-CEdJP2XP.js} +1 -1
  15. package/dist/web/assets/{git-log-panel-D9Obmd3x.js → git-log-panel-ClLf6-as.js} +1 -1
  16. package/dist/web/assets/{glide-data-grid-cWxDaC9T.js → glide-data-grid-CBhv99Ii.js} +1 -1
  17. package/dist/web/assets/{group-chat-tab-8lgr5lig.js → group-chat-tab-KTKtMy6L.js} +1 -1
  18. package/dist/web/assets/{image-preview-BDJ5dspF.js → image-preview-DYyo-Twv.js} +1 -1
  19. package/dist/web/assets/{index-BN3MQIrY.js → index-BiBAhdYZ.js} +3 -3
  20. package/dist/web/assets/{keybindings-store-BohTqglo.js → keybindings-store-C1yAVIlX.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-DgfXiX1E.js → markdown-renderer-CCBPLJC7.js} +1 -1
  22. package/dist/web/assets/{markdown-renderer-CzfDFcb9.js → markdown-renderer-JNQd7xG0.js} +1 -1
  23. package/dist/web/assets/{notification-store-D7YxkIs7.js → notification-store-lQg17iIv.js} +1 -1
  24. package/dist/web/assets/{pdf-preview-Bg5Df9YY.js → pdf-preview-CWnXgjqc.js} +1 -1
  25. package/dist/web/assets/{postgres-viewer-C0ibwn7l.js → postgres-viewer-BIXfNiGy.js} +1 -1
  26. package/dist/web/assets/{settings-tab-B0SUPLm8.js → settings-tab-CpltHPv5.js} +1 -1
  27. package/dist/web/assets/{sql-query-editor-B14enbgV.js → sql-query-editor-BDTzbvEM.js} +1 -1
  28. package/dist/web/assets/{sqlite-viewer-DL3s7Xcm.js → sqlite-viewer-BkOrBWZP.js} +1 -1
  29. package/dist/web/assets/{system-monitor-tab-Bw3Cy1dg.js → system-monitor-tab-s0Jm9zah.js} +1 -1
  30. package/dist/web/assets/{terminal-tab-Bz8t4LSC.js → terminal-tab-D3S_kz4Y.js} +1 -1
  31. package/dist/web/assets/{tool-cards-DOrdvoz5.js → tool-cards-DbhWrJ_o.js} +2 -2
  32. package/dist/web/assets/{use-monaco-theme-I6u-7ZEM.js → use-monaco-theme-DKGvvHk1.js} +1 -1
  33. package/dist/web/assets/{video-preview-CI_4NIE7.js → video-preview-BCU0PfT1.js} +1 -1
  34. package/dist/web/index.html +1 -1
  35. package/dist/web/sw.js +1 -1
  36. package/package.json +106 -106
  37. package/src/providers/claude-agent-sdk.ts +5 -6
  38. package/src/server/ws/chat.ts +27 -0
  39. package/src/services/edge-forwarder.ts +191 -0
  40. package/src/services/edge-target-resolver.ts +63 -0
  41. package/src/services/jsonl-transcript-parser.ts +45 -3
  42. package/src/services/slash-discovery/discover-skill-roots.ts +22 -3
  43. package/src/services/slash-discovery/index.ts +1 -0
  44. package/src/services/slash-discovery/rewrite-slash-alias.ts +35 -0
  45. package/src/services/slash-discovery/skill-loader.ts +40 -5
  46. package/src/services/slash-discovery/types.ts +6 -0
  47. package/src/types/api.ts +1 -0
  48. package/src/web/hooks/use-chat.ts +65 -1
  49. package/dist/web/assets/chat-tab-DhE2XceO.js +0 -13
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Edge forwarder — owns the public port and pipes raw TCP to the server's
3
+ * current loopback port.
4
+ *
5
+ * ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
6
+ *
7
+ * On Windows a child spawned with fd stdio inherits every inheritable handle,
8
+ * including a listening socket. When such a descendant is orphaned it keeps the
9
+ * socket open, so the port stays in LISTEN under a dead PID and can never be
10
+ * rebound — a "zombie port". The server hits this constantly because it spawns
11
+ * chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
12
+ *
13
+ * The edge exists precisely because it spawns nothing, so its socket cannot be
14
+ * inherited and its port cannot zombie. cloudflared can therefore stay pinned
15
+ * to it forever and the public URL stops rotating. Adding any child-process
16
+ * call to this file reintroduces the exact bug the edge was built to remove.
17
+ *
18
+ * Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
19
+ * untouched, and forwarded requests keep the headers cloudflared set.
20
+ */
21
+ import net from "node:net";
22
+ import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
23
+
24
+ /** Delay between attempts while waiting for the server to come back. */
25
+ const CONNECT_RETRY_MS = 250;
26
+ /**
27
+ * How long a client connection waits for a reachable server before giving up.
28
+ * During an upgrade the server is briefly absent; refusing instantly would
29
+ * surface as a broken page rather than a slow one.
30
+ */
31
+ const CONNECT_WINDOW_MS = 5000;
32
+
33
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
34
+
35
+ /** Resolve once the socket is connected; reject on the first connect error. */
36
+ function connectUpstream(port: number): Promise<net.Socket> {
37
+ return new Promise((resolvePromise, reject) => {
38
+ const upstream = net.connect({
39
+ port,
40
+ host: "127.0.0.1", // never taken from status.json — loopback only
41
+ allowHalfOpen: true,
42
+ });
43
+ const onError = (err: Error) => {
44
+ upstream.destroy();
45
+ reject(err);
46
+ };
47
+ upstream.once("error", onError);
48
+ upstream.once("connect", () => {
49
+ upstream.removeListener("error", onError);
50
+ resolvePromise(upstream);
51
+ });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Largest amount of client data held while waiting for the server. Bounds the
57
+ * memory a slow upload can pin during an upgrade window.
58
+ */
59
+ const MAX_PENDING_BYTES = 8 * 1024 * 1024;
60
+
61
+ /**
62
+ * Pipe one client connection to the server.
63
+ *
64
+ * Resolving the upstream is async, so the first bytes usually arrive before
65
+ * there is anywhere to send them. They must be captured by a `data` listener
66
+ * attached synchronously on this very tick and replayed once the upstream
67
+ * exists.
68
+ *
69
+ * `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
70
+ * that arrive while paused are lost, and a later `pipe()`/`resume()` does not
71
+ * bring them back. A socket with no `data` listener drops them outright too.
72
+ * Both variants silently swallowed the first request in testing.
73
+ */
74
+ async function handleConnection(
75
+ client: net.Socket,
76
+ connectWindowMs: number,
77
+ ): Promise<void> {
78
+ const pending: Buffer[] = [];
79
+ let pendingBytes = 0;
80
+ let clientEnded = false;
81
+ const onEarlyData = (chunk: Buffer) => {
82
+ pendingBytes += chunk.length;
83
+ if (pendingBytes > MAX_PENDING_BYTES) {
84
+ client.destroy();
85
+ return;
86
+ }
87
+ pending.push(chunk);
88
+ };
89
+ client.on("data", onEarlyData);
90
+ client.once("end", () => { clientEnded = true; });
91
+
92
+ let upstream: net.Socket | null = null;
93
+ let clientGone = false;
94
+
95
+ // A socket error with no listener is an unhandled 'error' event, which would
96
+ // take the whole edge process — and the public port — down with it.
97
+ const teardown = () => {
98
+ upstream?.destroy();
99
+ client.destroy();
100
+ };
101
+ client.on("error", teardown);
102
+ client.once("close", () => { clientGone = true; });
103
+
104
+ const deadline = Date.now() + connectWindowMs;
105
+ while (!clientGone && Date.now() < deadline) {
106
+ const port = resolveTargetPort();
107
+ if (port !== null) {
108
+ try {
109
+ upstream = await connectUpstream(port);
110
+ break;
111
+ } catch {
112
+ // Server not up yet (or moved mid-restart) — re-resolve and retry.
113
+ _resetTargetCache();
114
+ }
115
+ }
116
+ await sleep(CONNECT_RETRY_MS);
117
+ }
118
+
119
+ if (clientGone) return;
120
+ if (!upstream) {
121
+ client.destroy();
122
+ return;
123
+ }
124
+
125
+ upstream.on("error", teardown);
126
+
127
+ // Replay what arrived during the connect, then hand both directions to the
128
+ // stream machinery. Removing the listener and piping happen in the same tick,
129
+ // so no chunk can slip through the gap.
130
+ client.off("data", onEarlyData);
131
+ for (const chunk of pending) upstream.write(chunk);
132
+ pending.length = 0;
133
+
134
+ client.pipe(upstream);
135
+ upstream.pipe(client);
136
+
137
+ // The client may have half-closed while we were still connecting.
138
+ if (clientEnded) upstream.end();
139
+ }
140
+
141
+ export interface EdgeForwarderOptions {
142
+ publicPort: number;
143
+ host?: string;
144
+ /** How long a client waits for a reachable server. Defaults to 5s. */
145
+ connectWindowMs?: number;
146
+ }
147
+
148
+ /**
149
+ * Bind the public port and start forwarding. Resolves once listening so the
150
+ * caller can fail fast when the port is unavailable.
151
+ */
152
+ export function startEdgeForwarder(
153
+ opts: EdgeForwarderOptions,
154
+ ): Promise<net.Server> {
155
+ const host = opts.host ?? "0.0.0.0";
156
+ const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
157
+ return new Promise((resolvePromise, reject) => {
158
+ // allowHalfOpen keeps the other direction alive when one side sends FIN —
159
+ // long-lived chat WS and SSE streams depend on it.
160
+ const server = net.createServer({ allowHalfOpen: true }, (client) => {
161
+ void handleConnection(client, connectWindowMs);
162
+ });
163
+ server.once("error", reject);
164
+ server.listen(opts.publicPort, host, () => {
165
+ server.removeListener("error", reject);
166
+ resolvePromise(server);
167
+ });
168
+ });
169
+ }
170
+
171
+ // ─── Process entry ─────────────────────────────────────────────────────
172
+ // Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
173
+ // re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
174
+ if (process.argv.includes("__edge__")) {
175
+ const idx = process.argv.indexOf("__edge__");
176
+ const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
177
+ const host = process.argv[idx + 2] ?? "0.0.0.0";
178
+
179
+ if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
180
+ process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
181
+ process.exit(2);
182
+ }
183
+
184
+ try {
185
+ await startEdgeForwarder({ publicPort, host });
186
+ process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
187
+ } catch (err) {
188
+ process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
189
+ process.exit(1);
190
+ }
191
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Resolves which loopback port the edge forwarder should send traffic to.
3
+ *
4
+ * Split out of `edge-forwarder.ts` so the forwarder file stays about piping
5
+ * bytes and this one stays about trusting (or not trusting) the port it is told.
6
+ *
7
+ * The source of truth is `~/.ppm/.server-port`, written by the server itself
8
+ * the moment it binds — NOT `status.json`. Two reasons:
9
+ * 1. `writeStatus` replaces status.json wholesale at supervisor startup, so a
10
+ * supervisor restart would blank the target out from under a live edge.
11
+ * 2. The edge is deliberately detached and outlives the supervisor; it must
12
+ * not depend on the supervisor being alive to know where the server is.
13
+ * The supervisor mirrors the value into `status.json.serverPort` for `ppm
14
+ * status`, but that copy is observability only.
15
+ */
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { getPpmDir } from "./ppm-dir.ts";
19
+
20
+ /** File the server writes its bound port to. Single writer: the server. */
21
+ export const SERVER_PORT_FILE = () => resolve(getPpmDir(), ".server-port");
22
+
23
+ /** How long a resolved target port is trusted before re-reading from disk. */
24
+ const TARGET_CACHE_MS = 1000;
25
+
26
+ let cachedPort: number | null = null;
27
+ let cachedAt = 0;
28
+
29
+ /**
30
+ * Current server port, or null when it is absent/invalid.
31
+ *
32
+ * The value is validated rather than trusted: anything able to write the file
33
+ * would otherwise be able to redirect all tunnel traffic. Only a plausible port
34
+ * number is accepted, and callers must always dial loopback.
35
+ *
36
+ * Only successful reads are cached — caching a miss would stretch the
37
+ * forwarder's retry loop to one attempt per second during startup, which is
38
+ * exactly when the server is still coming up.
39
+ */
40
+ export function resolveTargetPort(now: number = Date.now()): number | null {
41
+ if (cachedPort !== null && now - cachedAt < TARGET_CACHE_MS) return cachedPort;
42
+
43
+ let raw: number;
44
+ try {
45
+ const file = SERVER_PORT_FILE();
46
+ if (!existsSync(file)) return null;
47
+ raw = parseInt(readFileSync(file, "utf-8").trim(), 10);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (!Number.isInteger(raw) || raw <= 0 || raw > 65535) return null;
53
+
54
+ cachedPort = raw;
55
+ cachedAt = now;
56
+ return cachedPort;
57
+ }
58
+
59
+ /** Drop the memoized target port (tests, and after a known server restart). */
60
+ export function _resetTargetCache(): void {
61
+ cachedPort = null;
62
+ cachedAt = 0;
63
+ }
@@ -154,6 +154,48 @@ export function nestChildEvents(events: ChatEvent[]): void {
154
154
  }
155
155
  }
156
156
 
157
+ /**
158
+ * Nest child events across message boundaries. A backgrounded subagent keeps
159
+ * running after its turn ends, so its events land in later messages than the
160
+ * Agent/Task tool_use that spawned it. Collects parents globally, moves each
161
+ * child event into its parent's children array, and blanks out messages left
162
+ * with nothing but moved child content (callers filter empty messages).
163
+ * Mutates messages in-place.
164
+ */
165
+ export function nestChildEventsAcrossMessages(messages: { content: string; events?: ChatEvent[] }[]): void {
166
+ const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
167
+ for (const msg of messages) {
168
+ for (const ev of msg.events ?? []) {
169
+ if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
170
+ parentMap.set(ev.toolUseId, ev);
171
+ }
172
+ }
173
+ }
174
+ if (parentMap.size === 0) return;
175
+
176
+ for (const msg of messages) {
177
+ if (!msg.events?.length) continue;
178
+ const kept: ChatEvent[] = [];
179
+ let moved = 0;
180
+ for (const ev of msg.events) {
181
+ const pid = (ev as any).parentToolUseId as string | undefined;
182
+ const parent = pid ? parentMap.get(pid) : undefined;
183
+ if (parent && parent !== ev) {
184
+ if (!parent.children) parent.children = [];
185
+ parent.children.push(ev);
186
+ moved++;
187
+ } else {
188
+ kept.push(ev);
189
+ }
190
+ }
191
+ if (moved === 0) continue;
192
+ msg.events = kept.length > 0 ? kept : undefined;
193
+ // A message that was purely subagent output duplicates its (now nested)
194
+ // text via `content` — blank it so the empty-message filter drops it.
195
+ if (kept.length === 0) msg.content = "";
196
+ }
197
+ }
198
+
157
199
  /**
158
200
  * Validate JSONL path — must be under ~/.claude/ (prevents arbitrary file reads).
159
201
  * Throws Error with descriptive message. Returns resolved realpath on success.
@@ -233,9 +275,9 @@ export async function parseJsonlTranscript(
233
275
  merged.push(msg);
234
276
  }
235
277
 
236
- for (const msg of merged) {
237
- if (msg.events) nestChildEvents(msg.events);
238
- }
278
+ // Nest across messages: a backgrounded subagent's events land in later
279
+ // messages than the Agent tool_use that spawned it.
280
+ nestChildEventsAcrossMessages(merged);
239
281
 
240
282
  return merged.filter(
241
283
  (msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
@@ -1,4 +1,4 @@
1
- import { resolve, dirname } from "node:path";
1
+ import { resolve, dirname, basename } from "node:path";
2
2
  import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
@@ -39,13 +39,14 @@ function addRoot(
39
39
  basePath: string,
40
40
  origin: ItemOrigin,
41
41
  source: DefinitionSource,
42
+ pluginName?: string,
42
43
  ): void {
43
44
  const full = resolve(basePath, origin);
44
45
  if (!isDir(full)) return;
45
46
  const resolved = resolve(full);
46
47
  if (seen.has(resolved)) return;
47
48
  seen.add(resolved);
48
- roots.push({ path: resolved, source, origin });
49
+ roots.push({ path: resolved, source, origin, ...(pluginName && { pluginName }) });
49
50
  }
50
51
 
51
52
  /**
@@ -143,6 +144,23 @@ export function resolveInstalledPlugins(pluginsDir: string): InstalledPlugin[] {
143
144
  }
144
145
  }
145
146
 
147
+ /**
148
+ * Plugin identifier used as the namespace prefix. The registry key is
149
+ * `plugin-id@marketplace-id`, so the id is everything before the `@`; scanned
150
+ * fallbacks read the manifest, and the directory name is the last resort.
151
+ */
152
+ export function resolvePluginName(plugin: InstalledPlugin): string {
153
+ const fromKey = plugin.key?.split("@")[0]?.trim();
154
+ if (fromKey) return fromKey;
155
+ try {
156
+ const manifest = JSON.parse(
157
+ readFileSync(resolve(plugin.path, ".claude-plugin", "plugin.json"), "utf-8"),
158
+ ) as { name?: unknown };
159
+ if (typeof manifest.name === "string" && manifest.name.trim()) return manifest.name.trim();
160
+ } catch { /* manifest missing or malformed — fall through */ }
161
+ return basename(plugin.path);
162
+ }
163
+
146
164
  /** Add roots for Claude Code plugins, which ship their own skills/commands/agents */
147
165
  function addPluginRoots(roots: SkillRoot[], seen: Set<string>, projectPath: string): void {
148
166
  const pluginsDir = resolve(homedir(), ".claude", "plugins");
@@ -150,8 +168,9 @@ function addPluginRoots(roots: SkillRoot[], seen: Set<string>, projectPath: stri
150
168
  const disabled = loadDisabledPluginKeys(projectPath);
151
169
  for (const plugin of resolveInstalledPlugins(pluginsDir)) {
152
170
  if (plugin.key && disabled.has(plugin.key)) continue;
171
+ const pluginName = resolvePluginName(plugin);
153
172
  for (const origin of ORIGINS) {
154
- addRoot(roots, seen, plugin.path, origin, "user-plugin");
173
+ addRoot(roots, seen, plugin.path, origin, "user-plugin", pluginName);
155
174
  }
156
175
  }
157
176
  }
@@ -11,6 +11,7 @@ export { isPpmHandled, getBuiltinByName } from "./builtin-commands.ts";
11
11
  export { executeBuiltin } from "./builtin-handlers.ts";
12
12
  export { invalidateCache, invalidateAll } from "./cache.ts";
13
13
  export { ensureSdkCommands, invalidateSdkCommands } from "./sdk-commands.ts";
14
+ export { rewriteSlashAlias } from "./rewrite-slash-alias.ts";
14
15
  export type { SlashItem, SlashItemWithSource, ShadowedItem, DiscoveryResult, SkillRoot, DefinitionSource } from "./types.ts";
15
16
 
16
17
  /**
@@ -0,0 +1,35 @@
1
+ import type { SlashItem } from "./types.ts";
2
+
3
+ /** Split a leading `/name` off a message, if there is one. */
4
+ function splitCommand(content: string): { name: string; rest: string } | null {
5
+ const match = content.match(/^\/(\S+)/);
6
+ if (!match) return null;
7
+ return { name: match[1]!, rest: content.slice(match[0].length) };
8
+ }
9
+
10
+ /**
11
+ * Rewrite a leading slash command that used a legacy alias into the name the
12
+ * runtime actually registers.
13
+ *
14
+ * Claude Code names a plugin item after its location, prefixed by the owning
15
+ * plugin (`/ak-engineer:ak-debug`). Kits that instead declare a self-namespaced
16
+ * frontmatter name (AgentKit ships `name: ak:debug`) publish a name nothing can
17
+ * resolve, so users typing the documented `/ak:debug` get a dead command. The
18
+ * declared name is kept as an alias during discovery; this turns it back into
19
+ * the canonical one.
20
+ *
21
+ * When two plugins claim the same alias — the AgentKit engineer and marketing
22
+ * kits overlap on ~30 skills — the first match in discovery order wins, which
23
+ * follows the plugin registry order.
24
+ *
25
+ * Unknown commands pass through untouched: reporting those is the SDK's job.
26
+ */
27
+ export function rewriteSlashAlias(content: string, items: SlashItem[]): string {
28
+ const cmd = splitCommand(content);
29
+ if (!cmd) return content;
30
+ // A name that already resolves is never rewritten, even if some other item
31
+ // happens to alias it.
32
+ if (items.some((item) => item.name === cmd.name)) return content;
33
+ const hit = items.find((item) => item.aliases?.includes(cmd.name));
34
+ return hit ? `/${hit.name}${cmd.rest}` : content;
35
+ }
@@ -53,6 +53,34 @@ function walkDir(dir: string, visitor: (filePath: string) => void, visited = new
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Resolve the invocable name for an item found under `root`.
58
+ *
59
+ * Plugin items are namespaced by their owning plugin, but the two kinds differ
60
+ * in what follows the prefix. A skill or command is named after where its file
61
+ * lives, so a kit that self-namespaces instead (AgentKit ships `name: ak:debug`)
62
+ * is not invocable under that declared name — it is kept as an alias so typed
63
+ * input can be rewritten to the canonical one. An agent is named after its
64
+ * frontmatter `name`, which is also how the runtime addresses it for delegation.
65
+ */
66
+ function qualify(
67
+ root: SkillRoot,
68
+ pathName: string,
69
+ declared: string | undefined,
70
+ nameSource: "path" | "declared" = "path",
71
+ ): { name: string; aliases?: string[] } {
72
+ if (!root.pluginName) return { name: declared ?? pathName };
73
+ if (nameSource === "declared") {
74
+ const suffix = declared || pathName;
75
+ return { name: suffix ? `${root.pluginName}:${suffix}` : "" };
76
+ }
77
+ // A SKILL.md sitting at the plugin root has no directory to take a name from
78
+ const suffix = pathName || declared;
79
+ if (!suffix) return { name: "" };
80
+ const name = `${root.pluginName}:${suffix}`;
81
+ return { name, ...(declared && declared !== name && { aliases: [declared] }) };
82
+ }
83
+
56
84
  /** Collect commands from a root with origin "commands" */
57
85
  function loadCommands(root: SkillRoot): SlashItemWithSource[] {
58
86
  const items: SlashItemWithSource[] = [];
@@ -66,10 +94,13 @@ function loadCommands(root: SkillRoot): SlashItemWithSource[] {
66
94
  const content = readFileSync(filePath, "utf-8");
67
95
  const { meta } = parseFrontmatter(content);
68
96
  const rel = relative(root.path, filePath);
69
- const name = rel.replace(/\.md$/, "").split(sep).join("/");
97
+ const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
98
+ const { name, aliases } = qualify(root, pathName, str(meta.name));
99
+ if (!name) return;
70
100
  items.push({
71
101
  type: "command",
72
- name: str(meta.name) ?? name,
102
+ name,
103
+ ...(aliases && { aliases }),
73
104
  description: str(meta.description) ?? "",
74
105
  argumentHint: str(meta["argument-hint"]),
75
106
  scope,
@@ -106,11 +137,12 @@ function loadSkills(root: SkillRoot): SlashItemWithSource[] {
106
137
  dirsWithSkillMd.add(skillDir);
107
138
  const rel = relative(root.path, skillDir);
108
139
  const pathName = rel.split(sep).join("/");
109
- const name = str(meta.name) ?? pathName;
140
+ const { name, aliases } = qualify(root, pathName, str(meta.name));
110
141
  if (!name) return;
111
142
  items.push({
112
143
  type: "skill",
113
144
  name,
145
+ ...(aliases && { aliases }),
114
146
  description: str(meta.description) ?? "",
115
147
  argumentHint: str(meta["argument-hint"]),
116
148
  scope,
@@ -136,11 +168,12 @@ function loadSkills(root: SkillRoot): SlashItemWithSource[] {
136
168
  const { meta } = parseFrontmatter(content);
137
169
  const rel = relative(root.path, filePath);
138
170
  const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
139
- const name = str(meta.name) ?? pathName;
171
+ const { name, aliases } = qualify(root, pathName, str(meta.name));
140
172
  if (!name) return;
141
173
  items.push({
142
174
  type: "skill",
143
175
  name,
176
+ ...(aliases && { aliases }),
144
177
  description: str(meta.description) ?? "",
145
178
  argumentHint: str(meta["argument-hint"]),
146
179
  scope,
@@ -168,7 +201,9 @@ function loadAgents(root: SkillRoot): SlashItemWithSource[] {
168
201
  const content = readFileSync(filePath, "utf-8");
169
202
  const { meta } = parseFrontmatter(content);
170
203
  const rel = relative(root.path, filePath);
171
- const name = str(meta.name) ?? rel.replace(/\.md$/, "").split(sep).join("/");
204
+ const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
205
+ const { name } = qualify(root, pathName, str(meta.name), "declared");
206
+ if (!name) return;
172
207
  items.push({
173
208
  type: "agent",
174
209
  name,
@@ -41,6 +41,12 @@ export interface SkillRoot {
41
41
  path: string; // Resolved absolute path
42
42
  source: DefinitionSource;
43
43
  origin: ItemOrigin;
44
+ /**
45
+ * Owning Claude Code plugin, set only for roots under ~/.claude/plugins/.
46
+ * Items below such a root are namespaced `<pluginName>:<path>`, matching how
47
+ * Claude Code itself registers them.
48
+ */
49
+ pluginName?: string;
44
50
  }
45
51
 
46
52
  /** Extends SlashItem with source metadata */
package/src/types/api.ts CHANGED
@@ -70,6 +70,7 @@ export type ChatWsServerMessage =
70
70
  | { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
71
71
  | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null; model?: string; effort?: string; thinking?: boolean }
72
72
  | { type: "turn_events"; events: unknown[] }
73
+ | { type: "user_message"; content: string; imageCount?: number; timestamp?: string }
73
74
  | { type: "title_updated"; title: string }
74
75
  | { type: "compact_status"; status: "compacting" | "done" }
75
76
  | { type: "ping" };
@@ -239,6 +239,46 @@ export function useChat(sessionId: string | null, providerId = "claude", project
239
239
  return true;
240
240
  }, []);
241
241
 
242
+ /**
243
+ * Fallback for child events arriving after their parent Agent/Task card was
244
+ * finalized: a backgrounded subagent keeps streaming past the turn's `done`
245
+ * (the SDK ends the turn at the boundary while the agent runs on), so the
246
+ * parent tool_use now lives in a finalized message, not the streaming buffer.
247
+ * Nest the child there instead of letting it render flat in the transcript.
248
+ */
249
+ const routeToFinalizedParent = useCallback((childEvent: ChatEvent, parentToolUseId: string): boolean => {
250
+ const isParent = (e: ChatEvent) =>
251
+ e.type === "tool_use" && (e.tool === "Agent" || e.tool === "Task") && (e as any).toolUseId === parentToolUseId;
252
+ const msgs = messagesRef.current;
253
+ let msgIdx = -1;
254
+ for (let i = msgs.length - 1; i >= 0; i--) {
255
+ if (msgs[i]!.events?.some(isParent)) { msgIdx = i; break; }
256
+ }
257
+ if (msgIdx === -1) return false;
258
+
259
+ setMessages((prev) => {
260
+ // Re-locate in prev — state may have shifted since the ref snapshot
261
+ let idx = -1;
262
+ for (let i = prev.length - 1; i >= 0; i--) {
263
+ if (prev[i]!.events?.some(isParent)) { idx = i; break; }
264
+ }
265
+ if (idx === -1) return prev;
266
+ const msg = prev[idx]!;
267
+ const events = msg.events!.map((e) => {
268
+ if (!isParent(e) || e.type !== "tool_use") return e;
269
+ const children = [...(e.children ?? [])];
270
+ // Replays can redeliver id-bearing children — upsert instead of duplicating
271
+ const cid = (childEvent as any).toolUseId as string | undefined;
272
+ const dup = cid ? children.findIndex((c) => c.type === childEvent.type && (c as any).toolUseId === cid) : -1;
273
+ if (dup !== -1) children[dup] = childEvent;
274
+ else children.push(childEvent);
275
+ return { ...e, children };
276
+ });
277
+ return [...prev.slice(0, idx), { ...msg, events }, ...prev.slice(idx + 1)];
278
+ });
279
+ return true;
280
+ }, []);
281
+
242
282
  /** Flush refs into React state (called from throttled timer or directly) */
243
283
  const flushMessages = useCallback(() => {
244
284
  syncRafRef.current = 0;
@@ -323,6 +363,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
323
363
  syncMessages();
324
364
  break;
325
365
  }
366
+ if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
326
367
  streamingContentRef.current += ev.content;
327
368
  streamingEventsRef.current.push(ev as ChatEvent);
328
369
  syncMessages();
@@ -335,6 +376,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
335
376
  syncMessages();
336
377
  break;
337
378
  }
379
+ if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
338
380
  streamingEventsRef.current.push(ev as ChatEvent);
339
381
  syncMessages();
340
382
  break;
@@ -352,6 +394,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
352
394
  syncMessages();
353
395
  break;
354
396
  }
397
+ if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
355
398
  const tuId = ev.toolUseId as string | undefined;
356
399
  upsertStreamingEvent((e) => !!tuId && e.type === "tool_use" && (e as any).toolUseId === tuId);
357
400
  syncMessages();
@@ -372,6 +415,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
372
415
  syncMessages();
373
416
  break;
374
417
  }
418
+ if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
375
419
  upsertStreamingEvent((e) => !!trId && e.type === "tool_result" && (e as any).toolUseId === trId);
376
420
  syncMessages();
377
421
  break;
@@ -593,7 +637,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
593
637
  break;
594
638
  }
595
639
  }
596
- }, [routeToParent, syncMessages]);
640
+ }, [routeToParent, routeToFinalizedParent, syncMessages]);
597
641
 
598
642
  const handleMessage = useCallback((event: MessageEvent) => {
599
643
  let data: ChatWsServerMessage;
@@ -610,6 +654,26 @@ export function useChat(sessionId: string | null, providerId = "claude", project
610
654
  // on the global bus (`use-global-events.ts`) instead of here — a chat socket is
611
655
  // not guaranteed to exist since chat tabs mount lazily.
612
656
 
657
+ // A user message sent from another device/tab of this session — render its
658
+ // bubble. The sender never receives this echo (server excludes the sender),
659
+ // so no dedupe against the optimistic append is needed.
660
+ if ((data as any).type === "user_message") {
661
+ const content = (data as any).content as string;
662
+ setMessages((prev) => {
663
+ const last = prev[prev.length - 1];
664
+ // Dedupe: a concurrent turn_events replay or REST refetch may already
665
+ // have appended this same turn's user message.
666
+ if (last?.role === "user" && last.content === content) return prev;
667
+ return [...prev, {
668
+ id: `user-remote-${Date.now()}`,
669
+ role: "user" as const,
670
+ content,
671
+ timestamp: (data as any).timestamp ?? new Date().toISOString(),
672
+ }];
673
+ });
674
+ return;
675
+ }
676
+
613
677
  // Handle title updates from SDK summary
614
678
  if ((data as any).type === "title_updated") {
615
679
  setSessionTitle((data as any).title ?? null);