@hienlh/ppm 0.17.55 → 0.17.57

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 (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-2h4gRHil.js → ai-resource-editor-CXWQcx3_.js} +1 -1
  5. package/dist/web/assets/{audio-preview-Duhsn8R0.js → audio-preview-D7jNc8mi.js} +1 -1
  6. package/dist/web/assets/{chat-tab-CdS8pzow.js → chat-tab-C5U7a_rP.js} +3 -3
  7. package/dist/web/assets/{code-editor-Bq5OC1R8.js → code-editor-CqlzLQ7T.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-DdX0vWbq.js → conflict-editor-CpGcSopK.js} +1 -1
  9. package/dist/web/assets/{csv-preview-DzCzp1eI.js → csv-preview-DD94T9ff.js} +1 -1
  10. package/dist/web/assets/{database-viewer-Bc0SxzHp.js → database-viewer-DTMmic31.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-D1_p6x3B.js → diff-viewer-DyQLYP7J.js} +1 -1
  12. package/dist/web/assets/{docx-preview-DwMr29B9.js → docx-preview-CR2ZtvNP.js} +1 -1
  13. package/dist/web/assets/{extension-webview-DekZ27i8.js → extension-webview-BTCFWY2A.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-BfND10PZ.js → git-log-panel-CrUgsLY5.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-CR7uGT3d.js → glide-data-grid-LinTYl0_.js} +1 -1
  16. package/dist/web/assets/{group-chat-tab-DtnJcJnm.js → group-chat-tab-BqNJW7y2.js} +1 -1
  17. package/dist/web/assets/{image-preview-IL6Io7vM.js → image-preview-0SGb7_tH.js} +1 -1
  18. package/dist/web/assets/{index-oAxj66VZ.js → index-I2ik0ZF7.js} +3 -3
  19. package/dist/web/assets/{keybindings-store-Dq7MhLxP.js → keybindings-store-C46tNxJE.js} +1 -1
  20. package/dist/web/assets/{markdown-renderer-BWY-49fv.js → markdown-renderer-DSxSupDq.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-SKo8Onun.js → markdown-renderer-xzyhJklS.js} +1 -1
  22. package/dist/web/assets/{notification-store-U1Rbcui7.js → notification-store-Dek7ClI2.js} +1 -1
  23. package/dist/web/assets/{pdf-preview-Dl5cTIGt.js → pdf-preview-DEn8amrO.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-DR_shYSN.js → postgres-viewer-CjrM15gz.js} +1 -1
  25. package/dist/web/assets/{settings-tab-DI2q_Qj3.js → settings-tab-DIuR1VIc.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-BFnHUYW2.js → sql-query-editor-CT5M9PKQ.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-DZAF9v9K.js → sqlite-viewer-CEObRQ7n.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-BIn4ZRWr.js → system-monitor-tab-De3oz9zD.js} +1 -1
  29. package/dist/web/assets/{terminal-tab-BI4jMhtL.js → terminal-tab-CciNqtMc.js} +1 -1
  30. package/dist/web/assets/{tool-cards-DooXE9m_.js → tool-cards-Bdx04yYD.js} +2 -2
  31. package/dist/web/assets/{use-monaco-theme-rmEGn72e.js → use-monaco-theme-k-17kQqf.js} +1 -1
  32. package/dist/web/assets/{video-preview-DDrh4ENk.js → video-preview-D-S_YnG6.js} +1 -1
  33. package/dist/web/index.html +1 -1
  34. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  35. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  36. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  37. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  38. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  39. package/dist/web/sw.js +1 -1
  40. package/package.json +1 -1
  41. package/src/providers/claude-agent-sdk.ts +6 -0
  42. package/src/providers/mock-provider.ts +10 -0
  43. package/src/server/index.ts +41 -15
  44. package/src/server/ws/chat.ts +8 -1
  45. package/src/services/autostart-generator.ts +24 -0
  46. package/src/services/autostart-register.ts +33 -4
  47. package/src/services/jsonl-transcript-parser.ts +6 -0
  48. package/src/services/subagent-transcript-merger.ts +144 -0
  49. package/src/web/app.tsx +1 -16
  50. package/src/web/hooks/use-chat.ts +25 -6
  51. package/src/web/hooks/use-global-events.ts +8 -2
  52. package/src/web/lib/sync-running-sessions.ts +27 -0
  53. package/src/web/stores/streaming-store.ts +29 -7
@@ -0,0 +1,144 @@
1
+ /**
2
+ * SubagentTranscriptMerger — restore Agent/Task card children on reload.
3
+ *
4
+ * Newer Claude Code CLIs no longer write subagent activity into the main
5
+ * session JSONL (no isSidechain lines, no parent_tool_use_id). Instead each
6
+ * agent gets its own transcript at:
7
+ * ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<id>.jsonl
8
+ * with a sibling agent-<id>.meta.json carrying the spawning Agent tool_use id.
9
+ *
10
+ * Without merging these, a reloaded session shows bare Agent cards and the
11
+ * live-streamed children are lost. This module reads the meta files, parses
12
+ * each agent transcript, and attaches its events as the card's children.
13
+ */
14
+
15
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import type { ChatEvent } from "../types/api.ts";
19
+ import { parseSessionMessage } from "./jsonl-transcript-parser.ts";
20
+
21
+ /**
22
+ * Locate the per-session directory (…/projects/<slug>/<sessionId>) that holds
23
+ * subagents/. The SDK encodes the cwd by replacing separators + drive colon
24
+ * with "-"; when that drifts (drive-letter case), fall back to scanning
25
+ * project dirs for the session's main JSONL.
26
+ */
27
+ export function resolveSessionDir(sessionId: string, projectPath: string | null | undefined): string | null {
28
+ const home = homedir();
29
+ const projectsRoot = join(home, ".claude", "projects");
30
+ if (projectPath) {
31
+ const encoded = projectPath.replace(/[/\\:]/g, "-");
32
+ const dir = join(projectsRoot, encoded);
33
+ if (existsSync(join(dir, `${sessionId}.jsonl`))) return join(dir, sessionId);
34
+ }
35
+ try {
36
+ for (const entry of readdirSync(projectsRoot)) {
37
+ if (existsSync(join(projectsRoot, entry, `${sessionId}.jsonl`))) {
38
+ return join(projectsRoot, entry, sessionId);
39
+ }
40
+ }
41
+ } catch {
42
+ /* projects root unreadable */
43
+ }
44
+ return null;
45
+ }
46
+
47
+ interface MessageLike {
48
+ content: string;
49
+ events?: ChatEvent[];
50
+ }
51
+
52
+ /** Payload guards — a session can hold dozens of agents with multi-MB transcripts. */
53
+ const MAX_CHILDREN_PER_AGENT = 2000;
54
+ const MAX_CHILD_OUTPUT_CHARS = 50_000;
55
+
56
+ /** Map of Agent-card toolUseId → agent transcript path, from subagents/*.meta.json */
57
+ function indexAgentTranscripts(subagentsDir: string): Map<string, string> {
58
+ const map = new Map<string, string>();
59
+ let entries: string[];
60
+ try {
61
+ entries = readdirSync(subagentsDir);
62
+ } catch {
63
+ return map;
64
+ }
65
+ for (const name of entries) {
66
+ if (!name.endsWith(".meta.json")) continue;
67
+ try {
68
+ const meta = JSON.parse(readFileSync(join(subagentsDir, name), "utf8"));
69
+ const toolUseId = meta?.toolUseId as string | undefined;
70
+ if (!toolUseId) continue;
71
+ const transcript = join(subagentsDir, name.replace(/\.meta\.json$/, ".jsonl"));
72
+ if (existsSync(transcript)) map.set(toolUseId, transcript);
73
+ } catch {
74
+ /* unreadable meta — skip this agent */
75
+ }
76
+ }
77
+ return map;
78
+ }
79
+
80
+ /** Parse one agent transcript into a flat children event list (stream order). */
81
+ function parseAgentTranscript(filePath: string): ChatEvent[] {
82
+ const children: ChatEvent[] = [];
83
+ let text: string;
84
+ try {
85
+ text = readFileSync(filePath, "utf8");
86
+ } catch {
87
+ return children;
88
+ }
89
+ let isFirstUser = true;
90
+ for (const line of text.split("\n")) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed) continue;
93
+ let entry: any;
94
+ try {
95
+ entry = JSON.parse(trimmed);
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
100
+ if (!entry.message) continue;
101
+ if (children.length >= MAX_CHILDREN_PER_AGENT) break;
102
+ // The first user record is the agent's spawn prompt — the live stream
103
+ // never showed it as a child, so skip it to match the streaming view.
104
+ if (entry.type === "user" && isFirstUser) {
105
+ isFirstUser = false;
106
+ const content = entry.message?.content;
107
+ const hasToolResult = Array.isArray(content) && content.some((b: any) => b?.type === "tool_result");
108
+ if (!hasToolResult) continue;
109
+ }
110
+ const parsed = parseSessionMessage(entry);
111
+ for (const ev of parsed.events ?? []) {
112
+ // Keep single events from ballooning the history payload (agent files
113
+ // can carry multi-MB tool outputs the live stream also showed in full,
114
+ // but 24 agents × full outputs breaks mobile reloads).
115
+ if (ev.type === "tool_result" && typeof ev.output === "string" && ev.output.length > MAX_CHILD_OUTPUT_CHARS) {
116
+ ev.output = ev.output.slice(0, MAX_CHILD_OUTPUT_CHARS) + "\n… [truncated]";
117
+ }
118
+ children.push(ev);
119
+ }
120
+ }
121
+ return children;
122
+ }
123
+
124
+ /**
125
+ * Attach subagent transcript events as children of their Agent/Task cards.
126
+ * `sessionDir` is the per-session directory next to the main JSONL
127
+ * (…/projects/<slug>/<sessionId>). Existing children are replaced — the disk
128
+ * transcript is the complete record, while live-collected children may be a
129
+ * partial overlap. Mutates messages in-place.
130
+ */
131
+ export function mergeSubagentChildren(sessionDir: string, messages: MessageLike[]): void {
132
+ const index = indexAgentTranscripts(join(sessionDir, "subagents"));
133
+ if (index.size === 0) return;
134
+
135
+ for (const msg of messages) {
136
+ for (const ev of msg.events ?? []) {
137
+ if (ev.type !== "tool_use" || (ev.tool !== "Agent" && ev.tool !== "Task") || !ev.toolUseId) continue;
138
+ const transcript = index.get(ev.toolUseId);
139
+ if (!transcript) continue;
140
+ const children = parseAgentTranscript(transcript);
141
+ if (children.length > 0) ev.children = children;
142
+ }
143
+ }
144
+ }
package/src/web/app.tsx CHANGED
@@ -16,8 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
16
16
  import { useTheme } from "@/theme/use-theme";
17
17
  import { initShikiThemeSync, warmShiki } from "@/theme/adapters/shiki-adapter";
18
18
  import { initMonacoThemeSync } from "@/theme/adapters/monaco-adapter";
19
- import { getAuthToken, api, projectUrl } from "@/lib/api-client";
20
- import { useStreamingStore } from "@/stores/streaming-store";
19
+ import { getAuthToken } from "@/lib/api-client";
21
20
  import { useUrlSync, parseUrlState, autoOpenFromUrl } from "@/hooks/use-url-sync";
22
21
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
23
22
  import { useNotificationBadge } from "@/hooks/use-notification-badge";
@@ -227,20 +226,6 @@ export function App() {
227
226
  });
228
227
  }, [authState, activeProject?.name]);
229
228
 
230
- // Seed which sessions are mid-turn. Chat tabs mount lazily, and a session's
231
- // phase otherwise only arrives over that tab's WebSocket — so without this a
232
- // background turn shows no tab-strip spinner and no title indicator.
233
- useEffect(() => {
234
- if (authState !== "authenticated" || !activeProject?.name) return;
235
- api
236
- .get<{ sessionId: string }[]>(`${projectUrl(activeProject.name)}/chat/sessions/running`)
237
- .then((running) => {
238
- const { setStreaming } = useStreamingStore.getState();
239
- for (const s of running) setStreaming(s.sessionId, true);
240
- })
241
- .catch(() => {}); // never block boot on the indicator
242
- }, [authState, activeProject?.name]);
243
-
244
229
  // Keep-alive: mount workspace on first visit, never unmount
245
230
  useEffect(() => {
246
231
  const projectName = activeProject?.name ?? "__global__";
@@ -216,9 +216,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
216
216
  // Sync streaming state to global store (for favicon + tab icon indicators)
217
217
  useEffect(() => {
218
218
  if (!sessionId) return;
219
- useStreamingStore.getState().setStreaming(sessionId, phase !== "idle");
219
+ // projectName tags the entry so a project-scoped sync can reconcile it.
220
+ useStreamingStore.getState().setStreaming(sessionId, phase !== "idle", projectName);
220
221
  return () => { useStreamingStore.getState().setStreaming(sessionId, false); };
221
- }, [sessionId, phase]);
222
+ }, [sessionId, phase, projectName]);
222
223
 
223
224
  /**
224
225
  * Route a child event to its parent Agent/Task tool_use's children array.
@@ -363,7 +364,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
363
364
  syncMessages();
364
365
  break;
365
366
  }
366
- if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
367
+ if (pid) {
368
+ // Parent card not found (e.g. replay raced the history fetch) — drop
369
+ // rather than rendering subagent output flat in the main transcript;
370
+ // the history merge restores it from the agent's disk transcript.
371
+ if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
372
+ break;
373
+ }
367
374
  streamingContentRef.current += ev.content;
368
375
  streamingEventsRef.current.push(ev as ChatEvent);
369
376
  syncMessages();
@@ -376,7 +383,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
376
383
  syncMessages();
377
384
  break;
378
385
  }
379
- if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
386
+ if (pid) {
387
+ // No parent found → drop (see text case) instead of flat-rendering.
388
+ if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
389
+ break;
390
+ }
380
391
  streamingEventsRef.current.push(ev as ChatEvent);
381
392
  syncMessages();
382
393
  break;
@@ -394,7 +405,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
394
405
  syncMessages();
395
406
  break;
396
407
  }
397
- if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
408
+ if (pid) {
409
+ // No parent found → drop (see text case) instead of flat-rendering.
410
+ if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
411
+ break;
412
+ }
398
413
  const tuId = ev.toolUseId as string | undefined;
399
414
  upsertStreamingEvent((e) => !!tuId && e.type === "tool_use" && (e as any).toolUseId === tuId);
400
415
  syncMessages();
@@ -415,7 +430,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
415
430
  syncMessages();
416
431
  break;
417
432
  }
418
- if (pid && routeToFinalizedParent(ev as ChatEvent, pid)) break;
433
+ if (pid) {
434
+ // No parent found → drop (see text case) instead of flat-rendering.
435
+ if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
436
+ break;
437
+ }
419
438
  upsertStreamingEvent((e) => !!trId && e.type === "tool_result" && (e as any).toolUseId === trId);
420
439
  syncMessages();
421
440
  break;
@@ -3,6 +3,7 @@ import { WsClient } from "@/lib/ws-client";
3
3
  import { getAuthToken } from "@/lib/api-client";
4
4
  import { useNotificationStore } from "@/stores/notification-store";
5
5
  import { useStreamingStore } from "@/stores/streaming-store";
6
+ import { syncRunningSessions } from "@/lib/sync-running-sessions";
6
7
 
7
8
  /**
8
9
  * App-wide event bus client (`/ws/global`).
@@ -18,6 +19,9 @@ import { useStreamingStore } from "@/stores/streaming-store";
18
19
  * - `session:phase_changed` → keeps the tab-strip spinner and title indicator
19
20
  * correct for sessions whose tab is not mounted, and — critically — clears them
20
21
  * when the turn ends. Nothing else can: `useChat` only runs while mounted.
22
+ * On every (re)connect the indicators are also reconciled against the server
23
+ * registry, since a phase change that happened while this socket was down was
24
+ * never delivered and would otherwise stick until a full reload.
21
25
  * - `jira:*` → re-dispatched as window events.
22
26
  *
23
27
  * Also tells the server which project to watch, so file watching follows the
@@ -53,6 +57,7 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
53
57
  if (projectRef.current) {
54
58
  client.send(JSON.stringify({ type: "watch", projectName: projectRef.current }));
55
59
  }
60
+ void syncRunningSessions(projectRef.current);
56
61
  return;
57
62
  }
58
63
 
@@ -73,8 +78,8 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
73
78
  }
74
79
 
75
80
  if (type === "session:phase_changed") {
76
- const d = data as unknown as { sessionId: string; phase: string };
77
- useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle");
81
+ const d = data as unknown as { sessionId: string; phase: string; projectName?: string };
82
+ useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle", d.projectName);
78
83
  return;
79
84
  }
80
85
 
@@ -97,5 +102,6 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
97
102
  useEffect(() => {
98
103
  if (!enabled || !projectName) return;
99
104
  clientRef.current?.send(JSON.stringify({ type: "watch", projectName }));
105
+ void syncRunningSessions(projectName);
100
106
  }, [enabled, projectName]);
101
107
  }
@@ -0,0 +1,27 @@
1
+ import { api, projectUrl } from "@/lib/api-client";
2
+ import { useStreamingStore } from "@/stores/streaming-store";
3
+
4
+ /**
5
+ * Reconcile a project's streaming indicators (tab-strip spinner, title/favicon)
6
+ * against the server's session registry.
7
+ *
8
+ * Runs on every `/ws/global` (re)connect and on project switch. Two gaps it
9
+ * closes, both of which nothing else can:
10
+ * - a turn running in a tab that is not mounted (tabs mount lazily, so its phase
11
+ * never reaches this client) → indicator missing;
12
+ * - an `idle` broadcast that landed while the global socket was down → indicator
13
+ * stuck on until a full page reload.
14
+ */
15
+ export async function syncRunningSessions(projectName: string | undefined): Promise<void> {
16
+ if (!projectName) return;
17
+ try {
18
+ const running = await api.get<{ sessionId: string }[]>(
19
+ `${projectUrl(projectName)}/chat/sessions/running`,
20
+ );
21
+ useStreamingStore
22
+ .getState()
23
+ .replaceProjectStreaming(projectName, running.map((s) => s.sessionId));
24
+ } catch {
25
+ // Never block boot or a reconnect on an indicator.
26
+ }
27
+ }
@@ -2,21 +2,43 @@ import { create } from "zustand";
2
2
 
3
3
  /** Tracks which chat sessions are currently streaming AI responses */
4
4
  interface StreamingStore {
5
- /** Set of sessionIds that are actively streaming */
6
- sessions: Set<string>;
5
+ /** sessionId projectName ("" when unknown) for sessions actively streaming.
6
+ * The project is recorded so a project-scoped sync can drop stale entries
7
+ * without touching sessions that belong to another project. */
8
+ sessions: Map<string, string>;
7
9
  /** Mark a session as streaming or idle */
8
- setStreaming: (sessionId: string, streaming: boolean) => void;
10
+ setStreaming: (sessionId: string, streaming: boolean, projectName?: string) => void;
11
+ /**
12
+ * Reconcile one project's streaming set against the server's registry, which
13
+ * is authoritative. Needed because an `idle` broadcast missed while
14
+ * `/ws/global` was down would otherwise leave a spinner running forever —
15
+ * nothing else ever clears it. Scoped per project: the running list only
16
+ * covers one project, so entries from other projects must survive.
17
+ */
18
+ replaceProjectStreaming: (projectName: string, sessionIds: string[]) => void;
9
19
  }
10
20
 
11
21
  export const useStreamingStore = create<StreamingStore>((set) => ({
12
- sessions: new Set(),
13
- setStreaming: (sessionId, streaming) =>
22
+ sessions: new Map(),
23
+ setStreaming: (sessionId, streaming, projectName) =>
14
24
  set((state) => {
15
- const next = new Set(state.sessions);
16
- if (streaming) next.add(sessionId);
25
+ const next = new Map(state.sessions);
26
+ // Keep a previously recorded project when a caller omits it, so the entry
27
+ // stays reconcilable.
28
+ if (streaming) next.set(sessionId, projectName ?? next.get(sessionId) ?? "");
17
29
  else next.delete(sessionId);
18
30
  return { sessions: next };
19
31
  }),
32
+ replaceProjectStreaming: (projectName, sessionIds) =>
33
+ set((state) => {
34
+ const running = new Set(sessionIds);
35
+ const next = new Map(state.sessions);
36
+ for (const [id, project] of state.sessions) {
37
+ if (project === projectName && !running.has(id)) next.delete(id);
38
+ }
39
+ for (const id of sessionIds) next.set(id, projectName);
40
+ return { sessions: next };
41
+ }),
20
42
  }));
21
43
 
22
44
  /** Selector: true if any session is streaming */