@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.
- package/CHANGELOG.md +3662 -3649
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-BywFZ8kq.js → ai-resource-editor-x8J0omsj.js} +1 -1
- package/dist/web/assets/{audio-preview-0l5TJLgd.js → audio-preview-Bdf_SZHY.js} +1 -1
- package/dist/web/assets/chat-tab-CT6SP5D3.js +13 -0
- package/dist/web/assets/{code-editor-BfuUP1De.js → code-editor-BgsJkCtK.js} +2 -2
- package/dist/web/assets/{conflict-editor-B47yD0OS.js → conflict-editor-DdSe9RCG.js} +1 -1
- package/dist/web/assets/{csv-preview-C7xm3SBD.js → csv-preview-DrUfLPRE.js} +1 -1
- package/dist/web/assets/{database-viewer-C7IEdTpP.js → database-viewer-1NLuRknj.js} +1 -1
- package/dist/web/assets/{diff-viewer-DRAU_SI7.js → diff-viewer-DPn8QJXG.js} +1 -1
- package/dist/web/assets/{docx-preview-BK7hQo6W.js → docx-preview-BUVtMDQF.js} +1 -1
- package/dist/web/assets/{extension-webview-JK1Z7erV.js → extension-webview-CEdJP2XP.js} +1 -1
- package/dist/web/assets/{git-log-panel-D9Obmd3x.js → git-log-panel-ClLf6-as.js} +1 -1
- package/dist/web/assets/{glide-data-grid-cWxDaC9T.js → glide-data-grid-CBhv99Ii.js} +1 -1
- package/dist/web/assets/{group-chat-tab-8lgr5lig.js → group-chat-tab-KTKtMy6L.js} +1 -1
- package/dist/web/assets/{image-preview-BDJ5dspF.js → image-preview-DYyo-Twv.js} +1 -1
- package/dist/web/assets/{index-BN3MQIrY.js → index-BiBAhdYZ.js} +3 -3
- package/dist/web/assets/{keybindings-store-BohTqglo.js → keybindings-store-C1yAVIlX.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DgfXiX1E.js → markdown-renderer-CCBPLJC7.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CzfDFcb9.js → markdown-renderer-JNQd7xG0.js} +1 -1
- package/dist/web/assets/{notification-store-D7YxkIs7.js → notification-store-lQg17iIv.js} +1 -1
- package/dist/web/assets/{pdf-preview-Bg5Df9YY.js → pdf-preview-CWnXgjqc.js} +1 -1
- package/dist/web/assets/{postgres-viewer-C0ibwn7l.js → postgres-viewer-BIXfNiGy.js} +1 -1
- package/dist/web/assets/{settings-tab-B0SUPLm8.js → settings-tab-CpltHPv5.js} +1 -1
- package/dist/web/assets/{sql-query-editor-B14enbgV.js → sql-query-editor-BDTzbvEM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DL3s7Xcm.js → sqlite-viewer-BkOrBWZP.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-Bw3Cy1dg.js → system-monitor-tab-s0Jm9zah.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bz8t4LSC.js → terminal-tab-D3S_kz4Y.js} +1 -1
- package/dist/web/assets/{tool-cards-DOrdvoz5.js → tool-cards-DbhWrJ_o.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-I6u-7ZEM.js → use-monaco-theme-DKGvvHk1.js} +1 -1
- package/dist/web/assets/{video-preview-CI_4NIE7.js → video-preview-BCU0PfT1.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +106 -106
- package/src/providers/claude-agent-sdk.ts +5 -6
- package/src/server/ws/chat.ts +27 -0
- package/src/services/edge-forwarder.ts +191 -0
- package/src/services/edge-target-resolver.ts +63 -0
- package/src/services/jsonl-transcript-parser.ts +45 -3
- package/src/services/slash-discovery/discover-skill-roots.ts +22 -3
- package/src/services/slash-discovery/index.ts +1 -0
- package/src/services/slash-discovery/rewrite-slash-alias.ts +35 -0
- package/src/services/slash-discovery/skill-loader.ts +40 -5
- package/src/services/slash-discovery/types.ts +6 -0
- package/src/types/api.ts +1 -0
- package/src/web/hooks/use-chat.ts +65 -1
- 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
|
-
|
|
237
|
-
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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);
|