pi-cc-extensions 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -18,6 +18,13 @@
18
18
  ## 截图
19
19
 
20
20
  <table>
21
+ <tr>
22
+ <td colspan="2" align="center">
23
+ <img src="./assets/readme/welcome.webp" width="100%" alt="Pi 的 Welcome 界面,展示 Tips、已加载上下文、扩展和最近会话">
24
+ <br>
25
+ <sub><b>Welcome 界面</b><br>启动时查看已加载上下文、扩展与最近会话</sub>
26
+ </td>
27
+ </tr>
21
28
  <tr>
22
29
  <td width="50%" align="center">
23
30
  <img src="./assets/readme/showcase.webp" width="100%" alt="Pi 终端中的 Claude Code 风格扩展界面,包含工具调用摘要、Diff 预览和 Powerline 状态栏">
@@ -86,7 +93,7 @@
86
93
  2. 从 `[Session] ...` 或 `[SubAgent] ...` 补全项中选择要引用的上下文。
87
94
  3. 提交时以 `@session:<session-id>` 引用其当前有效上下文。
88
95
 
89
- Session 模糊查询默认显示 3 个候选;若已加载 `pi-subagents`,也可直接引用现有 SubAgent。一次提示词可以引用多个 Session;扩展会自动去重,并限制注入规模以避免上下文无限膨胀。更多细节见 [`extensions/session-reference/README.md`](./extensions/session-reference/README.md)。
96
+ Session 模糊查询默认显示 5 个候选;若已加载 `pi-subagents`,也可直接引用现有 SubAgent。一次提示词可以引用多个 Session;扩展会自动去重,并限制注入规模以避免上下文无限膨胀。更多细节见 [`extensions/session-reference/README.md`](./extensions/session-reference/README.md)。
90
97
 
91
98
  ## 安装
92
99
 
@@ -133,9 +140,23 @@ pi install /absolute/path/to/pi-cc-extensions
133
140
  - Node.js `>=22.19.0`
134
141
  - 作为 Pi package 加载,入口由根目录 `package.json` 的 `pi.extensions` 显式声明
135
142
 
136
- ## 发布检查
143
+ ## 发布
144
+
145
+ 发布前检查:
137
146
 
138
147
  ```bash
139
148
  npm test
140
149
  npm pack --dry-run
141
150
  ```
151
+
152
+ 使用 npm 发布。未指定 tag 时使用 `latest`:
153
+
154
+ ```bash
155
+ npm run release
156
+ npm run release -- --tag next
157
+ npm run release -- --tag beta
158
+ ```
159
+
160
+ ## 致谢
161
+
162
+ 本项目中的 Powerline 状态栏、Welcome 界面、Working Vibes 等功能基于 [nicobailon/pi-powerline-footer](https://github.com/nicobailon/pi-powerline-footer) 二次开发,感谢原作者的开源贡献。
Binary file
@@ -5,6 +5,8 @@ interface CachedGitStatus {
5
5
  staged: number;
6
6
  unstaged: number;
7
7
  untracked: number;
8
+ added: number;
9
+ deleted: number;
8
10
  timestamp: number;
9
11
  }
10
12
 
@@ -19,19 +21,25 @@ const CACHE_TTL_MS = 1000; // 1 second for file status
19
21
  const BRANCH_TTL_MS = 500; // Shorter TTL so branch updates quickly after invalidation
20
22
  let cachedStatus: CachedGitStatus | null = null;
21
23
  let cachedBranch: CachedBranch | null = null;
24
+ let statusCwd: string | null = null;
25
+ let branchCwd: string | null = null;
22
26
  let pendingFetch: Promise<void> | null = null;
23
27
  let pendingBranchFetch: Promise<void> | null = null;
28
+ let fetchCwd: string | null = null;
29
+ let branchFetchCwd: string | null = null;
24
30
  let invalidationCounter = 0; // Track invalidations to prevent stale updates
25
31
  let branchInvalidationCounter = 0;
26
32
 
27
33
  /**
28
- * Parse git status --porcelain output
29
- *
34
+ * Parse git status --porcelain output.
35
+ *
30
36
  * Format: XY filename
31
37
  * X = index status, Y = working tree status
32
38
  * ?? = untracked
33
- * Other X values = staged
34
- * Other Y values = unstaged
39
+ *
40
+ * Each file is counted exactly once:
41
+ * - Y ≠ ' ' → unstaged (working tree has changes; if X also shows staged, Y wins)
42
+ * - X ≠ ' ' && X ≠ '?' → staged only (index has changes, working tree is clean)
35
43
  */
36
44
  function parseGitStatusOutput(output: string): { staged: number; unstaged: number; untracked: number } {
37
45
  let staged = 0;
@@ -48,24 +56,24 @@ function parseGitStatusOutput(output: string): { staged: number; unstaged: numbe
48
56
  continue;
49
57
  }
50
58
 
51
- // X position (index/staged)
52
- if (x && x !== " " && x !== "?") {
53
- staged++;
54
- }
55
-
56
- // Y position (working tree/unstaged)
59
+ // Y position (working tree) takes priority — if the file has working-tree
60
+ // changes at all, count it as unstaged.
57
61
  if (y && y !== " ") {
58
62
  unstaged++;
63
+ } else if (x && x !== " " && x !== "?") {
64
+ // X position (index) only counted when working tree is clean.
65
+ staged++;
59
66
  }
60
67
  }
61
68
 
62
69
  return { staged, unstaged, untracked };
63
70
  }
64
71
 
65
- function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
72
+ function runGit(args: string[], cwd: string, timeoutMs = 200): Promise<string | null> {
66
73
  return new Promise((resolve) => {
67
74
  const proc = spawn("git", args, {
68
75
  stdio: ["ignore", "pipe", "pipe"],
76
+ cwd,
69
77
  });
70
78
 
71
79
  let stdout = "";
@@ -83,7 +91,7 @@ function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
83
91
  });
84
92
 
85
93
  proc.on("close", (code) => {
86
- finish(code === 0 ? stdout.trim() : null);
94
+ finish(code === 0 ? stdout.replace(/\n+$/, "") : null);
87
95
  });
88
96
 
89
97
  proc.on("error", () => {
@@ -101,31 +109,50 @@ function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
101
109
  * Fetch current git branch asynchronously.
102
110
  * For detached HEAD, returns the short commit SHA (matches provider's "detached" behavior).
103
111
  */
104
- async function fetchGitBranch(): Promise<string | null> {
105
- const branch = await runGit(["branch", "--show-current"]);
112
+ async function fetchGitBranch(cwd: string): Promise<string | null> {
113
+ const branch = await runGit(["branch", "--show-current"], cwd);
106
114
  if (branch === null) return null;
107
115
  if (branch) return branch;
108
116
 
109
- const sha = await runGit(["rev-parse", "--short", "HEAD"]);
117
+ const sha = await runGit(["rev-parse", "--short", "HEAD"], cwd);
110
118
  return sha ? `${sha} (detached)` : "detached";
111
119
  }
112
120
 
113
121
  /**
114
- * Fetch git status asynchronously
122
+ * Parse git diff --shortstat HEAD output.
123
+ * Format: " N files changed, M insertions(+), K deletions(-)"
124
+ */
125
+ function parseDiffStat(output: string): { added: number; deleted: number } {
126
+ const added = (output.match(/(\d+) insertion/) ?? [])[1];
127
+ const deleted = (output.match(/(\d+) deletion/) ?? [])[1];
128
+ return {
129
+ added: added ? Number(added) : 0,
130
+ deleted: deleted ? Number(deleted) : 0,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Fetch line-level diff stats (additions / deletions) from HEAD.
115
136
  */
116
- async function fetchGitStatus(): Promise<{ staged: number; unstaged: number; untracked: number } | null> {
117
- const output = await runGit(["status", "--porcelain"], 500);
137
+ async function fetchDiffStat(cwd: string): Promise<{ added: number; deleted: number } | null> {
138
+ const output = await runGit(["diff", "--shortstat", "HEAD"], cwd, 1000);
118
139
  if (output === null) return null;
119
- return parseGitStatusOutput(output);
140
+ return parseDiffStat(output);
120
141
  }
121
142
 
122
143
  /**
123
144
  * Get the current git branch with caching.
124
145
  * Falls back to provider branch if our cache is empty.
125
146
  */
126
- export function getCurrentBranch(providerBranch: string | null): string | null {
147
+ export function getCurrentBranch(providerBranch: string | null, cwd: string): string | null {
127
148
  const now = Date.now();
128
149
 
150
+ // Invalidate cache if cwd changed (different repo)
151
+ if (branchCwd !== cwd) {
152
+ cachedBranch = null;
153
+ branchCwd = cwd;
154
+ }
155
+
129
156
  // Return cached if fresh
130
157
  if (cachedBranch && now - cachedBranch.timestamp < BRANCH_TTL_MS) {
131
158
  return cachedBranch.branch;
@@ -134,9 +161,10 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
134
161
  // Trigger background fetch if not already pending
135
162
  if (!pendingBranchFetch) {
136
163
  const fetchId = branchInvalidationCounter;
137
- pendingBranchFetch = fetchGitBranch().then((result) => {
138
- // Cache result if no invalidation happened (including null for non-git dirs)
139
- if (fetchId === branchInvalidationCounter) {
164
+ branchFetchCwd = cwd;
165
+ pendingBranchFetch = fetchGitBranch(cwd).then((result) => {
166
+ // Cache result if no invalidation happened AND cwd hasn't changed
167
+ if (fetchId === branchInvalidationCounter && branchFetchCwd === cwd) {
140
168
  cachedBranch = {
141
169
  branch: result,
142
170
  timestamp: Date.now(),
@@ -156,12 +184,18 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
156
184
  * This is designed for synchronous render() calls - returns last known value
157
185
  * while refreshing in background.
158
186
  */
159
- export function getGitStatus(providerBranch: string | null, pollingMode: GitPollingMode = "full"): GitStatus {
187
+ export function getGitStatus(providerBranch: string | null, cwd: string, pollingMode: GitPollingMode = "full"): GitStatus {
160
188
  const now = Date.now();
161
- const branch = pollingMode === "off" ? providerBranch : getCurrentBranch(providerBranch);
189
+ const branch = pollingMode === "off" ? providerBranch : getCurrentBranch(providerBranch, cwd);
162
190
 
163
191
  if (pollingMode !== "full") {
164
- return { branch, staged: 0, unstaged: 0, untracked: 0 };
192
+ return { branch, staged: 0, unstaged: 0, untracked: 0, added: 0, deleted: 0 };
193
+ }
194
+
195
+ // Invalidate cache if cwd changed (different repo)
196
+ if (statusCwd !== cwd) {
197
+ cachedStatus = null;
198
+ statusCwd = cwd;
165
199
  }
166
200
 
167
201
  // Return cached if fresh
@@ -171,18 +205,38 @@ export function getGitStatus(providerBranch: string | null, pollingMode: GitPoll
171
205
  staged: cachedStatus.staged,
172
206
  unstaged: cachedStatus.unstaged,
173
207
  untracked: cachedStatus.untracked,
208
+ added: cachedStatus.added,
209
+ deleted: cachedStatus.deleted,
174
210
  };
175
211
  }
176
212
 
177
213
  // Trigger background fetch if not already pending
178
214
  if (!pendingFetch) {
179
215
  const fetchId = invalidationCounter; // Capture current counter
180
- pendingFetch = fetchGitStatus().then((result) => {
181
- // Cache result if no invalidation happened (including null for non-git dirs)
182
- if (fetchId === invalidationCounter) {
183
- cachedStatus = result
184
- ? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: Date.now() }
185
- : { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
216
+ fetchCwd = cwd;
217
+ pendingFetch = Promise.all([
218
+ fetchGitStatus(cwd),
219
+ fetchDiffStat(cwd),
220
+ ]).then(([statusResult, diffResult]) => {
221
+ // Cache result if no invalidation happened AND cwd hasn't changed
222
+ if (fetchId === invalidationCounter && fetchCwd === cwd) {
223
+ cachedStatus = statusResult
224
+ ? {
225
+ staged: statusResult.staged,
226
+ unstaged: statusResult.unstaged,
227
+ untracked: statusResult.untracked,
228
+ added: diffResult?.added ?? 0,
229
+ deleted: diffResult?.deleted ?? 0,
230
+ timestamp: Date.now(),
231
+ }
232
+ : {
233
+ staged: 0,
234
+ unstaged: 0,
235
+ untracked: 0,
236
+ added: 0,
237
+ deleted: 0,
238
+ timestamp: Date.now(),
239
+ };
186
240
  }
187
241
  pendingFetch = null;
188
242
  });
@@ -195,10 +249,12 @@ export function getGitStatus(providerBranch: string | null, pollingMode: GitPoll
195
249
  staged: cachedStatus.staged,
196
250
  unstaged: cachedStatus.unstaged,
197
251
  untracked: cachedStatus.untracked,
252
+ added: cachedStatus.added,
253
+ deleted: cachedStatus.deleted,
198
254
  };
199
255
  }
200
256
 
201
- return { branch, staged: 0, unstaged: 0, untracked: 0 };
257
+ return { branch, staged: 0, unstaged: 0, untracked: 0, added: 0, deleted: 0 };
202
258
  }
203
259
 
204
260
  /**
@@ -2181,7 +2181,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2181
2181
 
2182
2182
  // Get git status (cached)
2183
2183
  const gitBranch = footerDataRef?.getGitBranch() ?? null;
2184
- const gitStatus = getGitStatus(gitBranch, segmentOptions.git?.polling);
2184
+ const gitStatus = getGitStatus(gitBranch, ctx.cwd, segmentOptions.git?.polling);
2185
2185
  const extensionStatuses = footerDataRef?.getExtensionStatuses() ?? new Map();
2186
2186
  const customItemsById = new Map(config.customItems.map((item) => [item.id, item]));
2187
2187
  const hiddenExtensionStatusKeys = collectHiddenExtensionStatusKeys(config.customItems);
@@ -31,7 +31,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
31
31
  segmentOptions: {
32
32
  model: { showThinkingLevel: false },
33
33
  path: { mode: "basename" },
34
- git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
34
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true, showLines: true },
35
35
  },
36
36
  },
37
37
 
@@ -42,7 +42,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
42
42
  colors: MINIMAL_COLORS,
43
43
  segmentOptions: {
44
44
  path: { mode: "basename" },
45
- git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
45
+ git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false, showLines: false },
46
46
  },
47
47
  },
48
48
 
@@ -53,7 +53,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
53
53
  colors: DEFAULT_COLORS,
54
54
  segmentOptions: {
55
55
  model: { showThinkingLevel: true },
56
- git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
56
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false, showLines: true },
57
57
  },
58
58
  },
59
59
 
@@ -65,7 +65,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
65
65
  segmentOptions: {
66
66
  model: { showThinkingLevel: false },
67
67
  path: { mode: "abbreviated", maxLength: 50 },
68
- git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
68
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true, showLines: true },
69
69
  time: { format: "24h", showSeconds: false },
70
70
  },
71
71
  },
@@ -78,7 +78,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
78
78
  segmentOptions: {
79
79
  model: { showThinkingLevel: false },
80
80
  path: { mode: "abbreviated", maxLength: 60 },
81
- git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
81
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true, showLines: true },
82
82
  time: { format: "24h", showSeconds: true },
83
83
  },
84
84
  },
@@ -91,7 +91,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
91
91
  segmentOptions: {
92
92
  model: { showThinkingLevel: true },
93
93
  path: { mode: "abbreviated", maxLength: 40 },
94
- git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
94
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true, showLines: false },
95
95
  },
96
96
  },
97
97
 
@@ -100,7 +100,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
100
100
  rightSegments: ["token_total", "cost", "context_pct"],
101
101
  separator: "pipe",
102
102
  colors: DEFAULT_COLORS,
103
- segmentOptions: {},
103
+ segmentOptions: {
104
104
  },
105
105
  };
106
106
 
@@ -130,7 +130,7 @@ const gitSegment: StatusLineSegment = {
130
130
  render(ctx) {
131
131
  const icons = getIcons();
132
132
  const opts = ctx.options.git ?? {};
133
- const { branch, staged, unstaged, untracked } = ctx.git;
133
+ const { branch, staged, unstaged, untracked, added, deleted } = ctx.git;
134
134
  const gitStatus = (staged > 0 || unstaged > 0 || untracked > 0)
135
135
  ? { staged, unstaged, untracked }
136
136
  : null;
@@ -169,6 +169,16 @@ const gitSegment: StatusLineSegment = {
169
169
  content += content ? ` ${indicatorText}` : indicatorText;
170
170
  }
171
171
  }
172
+
173
+ // Line-change indicators (added / deleted)
174
+ if (opts.showLines && (added > 0 || deleted > 0)) {
175
+ const lineParts: string[] = [];
176
+ if (added > 0) lineParts.push(applyColor(ctx.theme, "success", `+${added}`));
177
+ if (deleted > 0) lineParts.push(applyColor(ctx.theme, "error", `-${deleted}`));
178
+ if (lineParts.length > 0) {
179
+ content += ` ${lineParts.join(" ")}`;
180
+ }
181
+ }
172
182
  }
173
183
 
174
184
  if (!content) return { content: "", visible: false };
@@ -90,6 +90,7 @@ export interface StatusLineSegmentOptions {
90
90
  showStaged?: boolean;
91
91
  showUnstaged?: boolean;
92
92
  showUntracked?: boolean;
93
+ showLines?: boolean;
93
94
  polling?: "full" | "branch" | "off";
94
95
  };
95
96
  time?: { format?: "12h" | "24h"; showSeconds?: boolean };
@@ -136,6 +137,8 @@ export interface GitStatus {
136
137
  staged: number;
137
138
  unstaged: number;
138
139
  untracked: number;
140
+ added: number;
141
+ deleted: number;
139
142
  }
140
143
 
141
144
  // Usage statistics
@@ -837,6 +837,25 @@ export default function (pi: ExtensionAPI) {
837
837
 
838
838
  pi.registerCommand("ccstyle", {
839
839
  description: "Configure Claude Code style and Powerline preset",
840
+ getArgumentCompletions: (prefix) => {
841
+ const topLevel = [
842
+ { value: "on", label: "on", description: "Enable Claude Code style" },
843
+ { value: "off", label: "off", description: "Disable Claude Code style" },
844
+ { value: "status", label: "status", description: "Show current toggle and mode" },
845
+ { value: "minimal", label: "minimal", description: "Hide most tool output in collapsed view" },
846
+ { value: "compact", label: "compact", description: "Show short summaries in collapsed view" },
847
+ { value: "powerline", label: "powerline", description: "Set Powerline preset (e.g. powerline compact)" },
848
+ ];
849
+ // If the user typed "powerline " — suggest available presets
850
+ const match = prefix.match(/^powerline\s+/i);
851
+ if (match) {
852
+ const after = prefix.slice(match[0].length);
853
+ return powerline.getPresets()
854
+ .filter((p) => p.startsWith(after))
855
+ .map((p) => ({ value: `powerline ${p}`, label: p, description: `Powerline preset: ${p}` }));
856
+ }
857
+ return topLevel.filter((item) => item.value.startsWith(prefix));
858
+ },
840
859
  handler: async (args, ctx) => {
841
860
  const arg = args.trim().toLowerCase();
842
861
  const parts = arg.split(/\s+/).filter(Boolean);
@@ -11,7 +11,7 @@
11
11
  ## 行为
12
12
 
13
13
  - 搜索全部 pi 历史 Session;当前 Workspace 的 Session 优先。
14
- - Session 模糊查询默认最多显示 3 个候选。
14
+ - Session 模糊查询默认最多显示 5 个候选。
15
15
  - 与 pi 原有 `@` 文件补全并存。
16
16
  - 若已加载 `pi-subagents`,`@` 补全会同时显示现有 SubAgent,并引用其当前有效上下文。
17
17
  - 支持一个 prompt 引用多个 Session,并自动去重;单次最多注入前 5 个。
@@ -22,12 +22,25 @@ import {
22
22
  type ReferenceSource,
23
23
  } from "./core.ts";
24
24
 
25
- const MAX_SESSION_SUGGESTIONS = 3;
25
+ const MAX_SESSION_SUGGESTIONS = 5;
26
26
  const MAX_FILE_SUGGESTIONS = 10;
27
27
  const MAX_REFERENCED_SESSIONS = 5;
28
28
  const MENTION_PATTERN = /(?:^|[\t ])@([^\s@]*)$/;
29
29
  const SUBAGENT_MANAGER_KEY = Symbol.for("pi-subagents:manager");
30
30
 
31
+ // ── In-process subagent record tracker ──────────────────────────────
32
+ // pi-subagents does not expose a global manager; we track records
33
+ // ourselves by listening to the events it emits.
34
+
35
+ type SubagentLiveRecord = {
36
+ runId: string;
37
+ sessionId: string;
38
+ agent: string;
39
+ cwd: string;
40
+ startedAt: number;
41
+ completedAt?: number;
42
+ };
43
+
31
44
  type ReferenceDetails = {
32
45
  sessions: Array<{ id: string; title: string; cwd: string }>;
33
46
  };
@@ -40,27 +53,72 @@ type SessionReference = {
40
53
  messages?: unknown[];
41
54
  };
42
55
 
43
- type LiveSubagentSession = {
44
- sessionManager: {
45
- getSessionId(): string;
46
- getCwd(): string;
47
- buildSessionContext(): { messages: unknown[] };
48
- };
49
- sessionName?: string;
50
- };
51
-
52
56
  type SubagentRecord = {
53
57
  id: string;
54
58
  description?: string;
55
59
  startedAt?: number;
56
60
  completedAt?: number;
57
- session?: LiveSubagentSession;
58
61
  };
59
62
 
60
63
  type SubagentManager = {
61
64
  getRecord(id: string): SubagentRecord | undefined;
62
65
  };
63
66
 
67
+ // Local subagent record tracking (pi-subagents does not expose a global manager).
68
+ const liveSubagentRecords = new Map<string, SubagentLiveRecord>();
69
+
70
+ function trackSubagentFromEvent(data: unknown): void {
71
+ if (!data || typeof data !== "object") return;
72
+ const event = data as Record<string, unknown>;
73
+ // subagent:async-started payload has "id" as the run ID
74
+ // subagent:async-complete payload has "runId" as the run ID
75
+ const runId = (typeof event.id === "string" ? event.id : undefined)
76
+ ?? (typeof event.runId === "string" ? event.runId : undefined);
77
+ if (!runId) return;
78
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId : undefined;
79
+ if (!sessionId) return;
80
+
81
+ const existing = liveSubagentRecords.get(runId);
82
+ if (existing) {
83
+ // Completion event — mark completedAt
84
+ if (typeof event.endedAt === "number" || typeof event.lastUpdate === "number") {
85
+ existing.completedAt = (typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate) as number;
86
+ }
87
+ return;
88
+ }
89
+ // Started event — create new record
90
+ const agent = typeof event.agent === "string" ? event.agent : "";
91
+ const cwd = typeof event.cwd === "string" ? event.cwd : "";
92
+ const startedAt = typeof event.startedAt === "number" ? event.startedAt as number : Date.now();
93
+ liveSubagentRecords.set(runId, {
94
+ runId,
95
+ sessionId,
96
+ agent,
97
+ cwd,
98
+ startedAt,
99
+ });
100
+ }
101
+
102
+ function getSubagentManager(): SubagentManager | undefined {
103
+ // Try the global manager first (future-proof), fall back to local records.
104
+ const manager = (globalThis as any)[SUBAGENT_MANAGER_KEY] as SubagentManager | undefined;
105
+ if (manager && typeof manager.getRecord === "function") return manager;
106
+ // If no global manager, use our local tracking.
107
+ if (liveSubagentRecords.size === 0) return undefined;
108
+ return {
109
+ getRecord(id: string): SubagentRecord | undefined {
110
+ const record = liveSubagentRecords.get(id);
111
+ if (!record) return undefined;
112
+ return {
113
+ id: record.runId,
114
+ description: record.agent || undefined,
115
+ startedAt: record.startedAt,
116
+ completedAt: record.completedAt,
117
+ };
118
+ },
119
+ };
120
+ }
121
+
64
122
  function extractMentionQuery(textBeforeCursor: string): string | undefined {
65
123
  return textBeforeCursor.match(MENTION_PATTERN)?.[1];
66
124
  }
@@ -108,67 +166,34 @@ function filterSessions(references: SessionReference[], query: string, currentCw
108
166
  return matches.slice(0, MAX_SESSION_SUGGESTIONS).map((reference) => sessionItem(reference, currentCwd));
109
167
  }
110
168
 
111
- function getSubagentManager(): SubagentManager | undefined {
112
- const manager = (globalThis as any)[SUBAGENT_MANAGER_KEY] as SubagentManager | undefined;
113
- return manager && typeof manager.getRecord === "function" ? manager : undefined;
114
- }
115
-
116
- function textFromMessage(message: unknown): string {
117
- if (!message || typeof message !== "object") return "";
118
- const value = message as Record<string, unknown>;
119
- if (typeof value.content === "string") return value.content;
120
- if (!Array.isArray(value.content)) return "";
121
- return value.content
122
- .filter((part): part is Record<string, unknown> => Boolean(part) && typeof part === "object")
123
- .filter((part) => part.type === "text" && typeof part.text === "string")
124
- .map((part) => part.text as string)
125
- .join("\n");
126
- }
127
-
128
- function firstUserMessage(messages: unknown[]): string {
129
- for (const message of messages) {
130
- if (!message || typeof message !== "object") continue;
131
- const role = (message as Record<string, unknown>).role;
132
- if (role !== "user") continue;
133
- const text = textFromMessage(message).trim();
134
- if (text) return text;
135
- }
136
- return "(no messages)";
137
- }
138
-
139
- function liveSubagentReferences(agentIds: Set<string>, currentSessionId: string): SessionReference[] {
169
+ function filterSessions(agentIds: Set<string>, currentSessionId: string): SessionReference[] {
140
170
  const manager = getSubagentManager();
141
171
  if (!manager) return [];
142
172
 
143
173
  const references: SessionReference[] = [];
144
174
  for (const agentId of agentIds) {
145
175
  const record = manager.getRecord(agentId);
146
- const session = record?.session;
147
- if (!session) continue;
148
-
149
- try {
150
- const sessionManager = session.sessionManager;
151
- const sessionId = sessionManager.getSessionId();
152
- if (!sessionId || sessionId === currentSessionId) continue;
153
- const messages = sessionManager.buildSessionContext().messages;
154
- const name = record.description?.trim() || session.sessionName?.trim();
155
- const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
156
- references.push({
157
- kind: "subagent",
158
- referenceIds: [sessionId, agentId],
159
- info: {
160
- id: sessionId,
161
- name,
162
- cwd: sessionManager.getCwd(),
163
- firstMessage: firstUserMessage(messages),
164
- messageCount: messages.length,
165
- modified: new Date(modifiedAt),
166
- },
167
- messages,
168
- });
169
- } catch {
170
- // A subagent can finish or be disposed while the suggestion list is loading.
171
- }
176
+ if (!record) continue;
177
+
178
+ // Try the global manager's live session first (future-proof).
179
+ const liveRecord = liveSubagentRecords.get(agentId);
180
+ const sessionId = liveRecord?.sessionId ?? record.id;
181
+ if (!sessionId || sessionId === currentSessionId) continue;
182
+
183
+ const name = record.description?.trim() || liveRecord?.agent || undefined;
184
+ const modifiedAt = record.completedAt ?? record.startedAt ?? Date.now();
185
+ references.push({
186
+ kind: "subagent",
187
+ referenceIds: [sessionId, agentId],
188
+ info: {
189
+ id: sessionId,
190
+ name,
191
+ cwd: liveRecord?.cwd ?? "",
192
+ firstMessage: "",
193
+ messageCount: 0,
194
+ modified: new Date(modifiedAt),
195
+ },
196
+ });
172
197
  }
173
198
  return references;
174
199
  }
@@ -239,11 +264,18 @@ function samePath(left: string | undefined, right: string): boolean {
239
264
  export default function sessionReferenceExtension(pi: ExtensionAPI): void {
240
265
  let getAvailableReferences: (() => Promise<SessionReference[]>) | undefined;
241
266
  const subagentIds = new Set<string>();
242
- const unsubscribeSubagentEvents = ["subagents:created", "subagents:started", "subagents:completed", "subagents:failed"].map(
267
+ // pi-subagents emits "subagent:async-started" and "subagent:async-complete" events.
268
+ // We track records locally so @[SubAgent] suggestions work even without a global manager.
269
+ const subagentEventNames = ["subagent:async-started", "subagent:async-complete"];
270
+ const unsubscribeSubagentEvents = subagentEventNames.map(
243
271
  (eventName) =>
244
272
  pi.events.on(eventName, (data) => {
273
+ trackSubagentFromEvent(data);
245
274
  if (!data || typeof data !== "object") return;
246
- const id = (data as Record<string, unknown>).id;
275
+ const payload = data as Record<string, unknown>;
276
+ // subagent:async-started uses "id", subagent:async-complete uses "runId"
277
+ const id = (typeof payload.id === "string" ? payload.id : undefined)
278
+ ?? (typeof payload.runId === "string" ? payload.runId : undefined);
247
279
  if (typeof id === "string" && id) subagentIds.add(id);
248
280
  }),
249
281
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -20,7 +20,9 @@
20
20
  ]
21
21
  },
22
22
  "scripts": {
23
- "test": "node --test extensions/session-reference/core.test.ts"
23
+ "test": "node --test extensions/session-reference/core.test.ts",
24
+ "prepublishOnly": "npm test",
25
+ "release": "npm publish"
24
26
  },
25
27
  "peerDependencies": {
26
28
  "@earendil-works/pi-ai": "*",