@evandu/super-pi 0.1.145 → 0.1.146

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 (75) hide show
  1. package/agents/super-pi-task/core.ts +15 -2
  2. package/agents/super-pi-task/index.ts +3 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +9 -9
  5. package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-CEwBCVM6.js} +2 -2
  6. package/dist/client/assets/{dist-Br-AuQUG.js → dist-76ArZ5M4.js} +1 -1
  7. package/dist/client/assets/{dist-DSHM-PAs.js → dist-9F1wL-T5.js} +1 -1
  8. package/dist/client/assets/{dist-UJ46aPS3.js → dist-B0RXZeXQ.js} +1 -1
  9. package/dist/client/assets/{dist-BAiV8I_O.js → dist-BCNaDgNW.js} +1 -1
  10. package/dist/client/assets/{dist-4JbFTwg2.js → dist-BXBSIuTD.js} +1 -1
  11. package/dist/client/assets/{dist-UPJPMjWv.js → dist-Bh8Q7Y_n.js} +1 -1
  12. package/dist/client/assets/{dist-CIgjifH-.js → dist-BoyICDlL.js} +1 -1
  13. package/dist/client/assets/{dist-2boAf_pN.js → dist-BuR5Iagd.js} +1 -1
  14. package/dist/client/assets/{dist-B1p4YF9C.js → dist-Bx6ZWYUW.js} +1 -1
  15. package/dist/client/assets/{dist-BTVbZigv.js → dist-CAOcjKAQ.js} +1 -1
  16. package/dist/client/assets/{dist-CjszAoXk.js → dist-CHt8PX8R.js} +1 -1
  17. package/dist/client/assets/{dist-bIDce5j-.js → dist-CSCUT1PQ.js} +1 -1
  18. package/dist/client/assets/{dist-Ds5gfeAi.js → dist-CUB_Chdh.js} +1 -1
  19. package/dist/client/assets/{dist-D-o7iQ8c.js → dist-CbVf2Cjn.js} +1 -1
  20. package/dist/client/assets/{dist-B9DE7sc5.js → dist-DBUqwiWG.js} +1 -1
  21. package/dist/client/assets/{dist-DM4Olwfx.js → dist-DC1IocrS.js} +1 -1
  22. package/dist/client/assets/{dist-CK2KFFtT.js → dist-DFMtP3wG.js} +1 -1
  23. package/dist/client/assets/{dist-BYOpvW02.js → dist-DN0GlnnZ.js} +1 -1
  24. package/dist/client/assets/{dist-CQFASxyb.js → dist-DftD4ibj.js} +1 -1
  25. package/dist/client/assets/{dist-BlxwDnWr.js → dist-Do52MRwm.js} +1 -1
  26. package/dist/client/assets/{dist-CQV5kOCE.js → dist-JvQIIdqB.js} +1 -1
  27. package/dist/client/assets/{dist-CRnm1rnO.js → dist-oLq6HXLG.js} +1 -1
  28. package/dist/client/assets/{index-CiIc734l.css → index-D-OxlUxN.css} +1 -1
  29. package/dist/client/assets/index-E7yaLa2C.js +102 -0
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/agents.d.ts +1 -1
  32. package/dist/server/agents.js +3 -1
  33. package/dist/server/application/conversation-service.d.ts +1 -1
  34. package/dist/server/application/conversation-service.js +6 -4
  35. package/dist/server/application/run-service.d.ts +16 -2
  36. package/dist/server/application/run-service.js +121 -14
  37. package/dist/server/application/scheduled-task-service.d.ts +14 -1
  38. package/dist/server/application/scheduled-task-service.js +27 -2
  39. package/dist/server/application/session-orchestrator-service.d.ts +11 -2
  40. package/dist/server/application/session-orchestrator-service.js +103 -27
  41. package/dist/server/command-handler.js +1 -1
  42. package/dist/server/conversation-usage.d.ts +19 -0
  43. package/dist/server/conversation-usage.js +40 -0
  44. package/dist/server/file-diff.d.ts +3 -0
  45. package/dist/server/file-diff.js +4 -4
  46. package/dist/server/file-index.d.ts +4 -2
  47. package/dist/server/file-index.js +14 -9
  48. package/dist/server/file-name-index.d.ts +5 -2
  49. package/dist/server/file-name-index.js +22 -5
  50. package/dist/server/git.d.ts +3 -3
  51. package/dist/server/git.js +10 -9
  52. package/dist/server/http-server.js +195 -33
  53. package/dist/server/model-context-window.d.ts +46 -0
  54. package/dist/server/model-context-window.js +121 -0
  55. package/dist/server/pi-session.d.ts +6 -0
  56. package/dist/server/pi-session.js +15 -1
  57. package/dist/server/runtime.d.ts +3 -0
  58. package/dist/server/runtime.js +21 -6
  59. package/dist/server/session-cwd.d.ts +17 -0
  60. package/dist/server/session-cwd.js +43 -0
  61. package/dist/server/session-ledger.d.ts +7 -0
  62. package/dist/server/store.d.ts +22 -0
  63. package/dist/server/store.js +52 -0
  64. package/dist/server/task-tool.d.ts +2 -1
  65. package/dist/server/task-tool.js +3 -1
  66. package/dist/server/terminal.d.ts +1 -0
  67. package/dist/server/terminal.js +5 -3
  68. package/dist/server/turn-failure.js +16 -1
  69. package/dist/server/types.d.ts +18 -0
  70. package/dist/server/version.d.ts +25 -2
  71. package/dist/server/version.js +98 -26
  72. package/dist/server/ws-server.d.ts +1 -1
  73. package/dist/server/ws-server.js +7 -5
  74. package/package.json +20 -3
  75. package/dist/client/assets/index-BGsP8pt_.js +0 -100
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { capText } from "../workflow.js";
4
+ import { assertSessionCwdInsideWorkspace } from "../session-cwd.js";
4
5
  export const SESSION_OUTPUT_CHAR_LIMIT = 64 * 1024;
5
6
  export const sessionResultRelativePath = (conversationId) => `.super-pi/session-results/${conversationId}.md`;
6
7
  /**
@@ -26,14 +27,15 @@ export class SessionOrchestratorService {
26
27
  * Spawns a new sibling conversation and immediately dispatches its initial task.
27
28
  */
28
29
  async spawn(options) {
29
- let workspaceId = options.workspaceId;
30
- if (!workspaceId && options.parentConversationId) {
31
- const parent = this.store.conversation(options.parentConversationId);
32
- workspaceId = parent?.workspaceId;
33
- }
30
+ const workspaceId = options.workspaceId?.trim();
34
31
  if (!workspaceId) {
35
- throw new Error("创建兄弟会话需要指定工作区(或提供有效的父会话 ID)。");
32
+ throw new Error("创建兄弟会话必须显式指定 workspaceId;父会话只记录树形关系,不能代替目标工作区。");
36
33
  }
34
+ const workspace = this.store.workspace(workspaceId);
35
+ if (!workspace)
36
+ throw new Error("找不到这个工作区。");
37
+ await assertSessionCwdInsideWorkspace(workspace.path, options.cwd);
38
+ const cwd = options.cwd;
37
39
  // THE PARENTAGE IS WRITTEN AT CREATION TIME, onto the conversation itself - not only into the
38
40
  // in-memory ledger below. The ledger is task/status/progress metadata and dies with the process;
39
41
  // the conversation's parentConversationId/origin is what the sidebar's session tree redraws
@@ -41,7 +43,7 @@ export class SessionOrchestratorService {
41
43
  const conversationId = await this.conversationService.createConversation(workspaceId, {
42
44
  parentConversationId: options.parentConversationId,
43
45
  origin: options.parentConversationId ? "orchestrator" : "user",
44
- });
46
+ }, cwd);
45
47
  // Set title if requested or derive from task
46
48
  if (options.title) {
47
49
  await this.conversationService.renameConversation(conversationId, options.title);
@@ -88,7 +90,11 @@ export class SessionOrchestratorService {
88
90
  this.ledger.update(conversationId, {
89
91
  status: "running",
90
92
  });
91
- const runPromise = this.runService.enqueueAndRun(conversationId, content);
93
+ // THE ORDER IS THE SYSTEM'S, NOT THE READER'S. It used to go through `enqueueAndRun`, which
94
+ // writes the words under the reader's own name - so the child's transcript showed an
95
+ // instruction the reader never typed, wearing the reader's avatar, and a reader who looked at
96
+ // the child session saw their own name on somebody else's order.
97
+ const runPromise = this.runService.enqueueAndRunAsAgent(conversationId, content);
92
98
  const completion = (async () => {
93
99
  let failure;
94
100
  try {
@@ -175,11 +181,22 @@ export class SessionOrchestratorService {
175
181
  if (conversation.status === "running" || conversation.status === "waiting") {
176
182
  const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
177
183
  if (lastAssistant?.toolCalls?.length) {
178
- const lastCall = lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1];
179
- if (!lastCall.status || lastCall.live || lastCall.preparing) {
180
- activeTool = { label: lastCall.label, detail: lastCall.detail };
184
+ const isCompleted = (t) => t.status === "ok" || t.status === "error" || t.status === "done" || t.status === "failed";
185
+ const isRunning = (t) => !isCompleted(t) && (Boolean(t.preparing) || Boolean(t.live) || !t.status || t.status === "running");
186
+ const runningCall = [...lastAssistant.toolCalls].reverse().find(isRunning)
187
+ ?? (!isCompleted(lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1])
188
+ ? lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1]
189
+ : undefined);
190
+ if (runningCall) {
191
+ activeTool = {
192
+ label: runningCall.label || runningCall.name || "tool",
193
+ detail: runningCall.detail || runningCall.live,
194
+ };
181
195
  }
182
196
  }
197
+ if (!activeTool && ledgerEntry?.activeTool) {
198
+ activeTool = { ...ledgerEntry.activeTool };
199
+ }
183
200
  }
184
201
  const hasPendingQuestion = this.store.pendingQuestions(conversationId).length > 0;
185
202
  let output;
@@ -200,54 +217,113 @@ export class SessionOrchestratorService {
200
217
  }
201
218
  }
202
219
  }
220
+ const effectiveStatus = conversation.error
221
+ ? "failed"
222
+ : conversation.status;
223
+ const finalProgress = lastAssistantSnippet ?? ledgerEntry?.lastProgress ?? lastUserSnippet;
203
224
  // Sync ledger status if present
204
- if (ledgerEntry && ledgerEntry.status !== conversation.status) {
225
+ if (ledgerEntry && (ledgerEntry.status !== effectiveStatus || ledgerEntry.activeTool !== activeTool || ledgerEntry.lastProgress !== finalProgress)) {
205
226
  this.ledger.update(conversationId, {
206
- status: conversation.status,
207
- lastProgress: lastAssistantSnippet ?? ledgerEntry.lastProgress,
227
+ status: effectiveStatus,
228
+ lastProgress: finalProgress,
229
+ activeTool,
230
+ ...(conversation.error ? { error: conversation.error } : {}),
208
231
  });
209
232
  }
210
233
  return {
211
234
  conversationId: conversation.id,
212
235
  workspaceId: conversation.workspaceId,
213
236
  title: conversation.title,
214
- status: conversation.status,
215
- origin: ledgerEntry?.origin ?? "user",
237
+ status: effectiveStatus,
238
+ origin: ledgerEntry?.origin ?? conversation.origin ?? "user",
216
239
  parentConversationId: conversation.parentConversationId ?? ledgerEntry?.parentConversationId,
217
- task: ledgerEntry?.task,
240
+ task: ledgerEntry?.task ?? (conversation.title && conversation.title !== "新会话" ? conversation.title : undefined),
218
241
  messageCount: messages.length,
219
242
  turnCount,
220
243
  lastAssistantSnippet,
221
244
  lastUserSnippet,
222
245
  hasPendingQuestion,
223
246
  activeTool,
224
- error: conversation.error,
247
+ error: conversation.error ?? ledgerEntry?.error,
225
248
  updatedAt: conversation.updatedAt,
226
- createdAt: ledgerEntry?.createdAt,
227
- lastProgress: ledgerEntry?.lastProgress ?? lastAssistantSnippet,
249
+ createdAt: ledgerEntry?.createdAt ?? (conversation.messages?.[0]?.createdAt ? new Date(conversation.messages[0].createdAt).getTime() : (conversation.updatedAt ? new Date(conversation.updatedAt).getTime() : Date.now())),
250
+ lastProgress: finalProgress,
228
251
  ...(options?.includeOutput ? { outputPath: resultPath } : {}),
229
252
  ...(output !== undefined ? { output, outputTruncated } : {}),
230
253
  };
231
254
  }
232
255
  /**
233
256
  * Lists summaries of all sessions, optionally filtered by parent conversation.
257
+ *
258
+ * Persisted store conversations are the authoritative source for session tree
259
+ * parent-child relationships across server restarts, while SessionLedger
260
+ * enriches live in-memory task, progress, and activeTool state.
234
261
  */
235
262
  async list(parentConversationId) {
236
- const entries = parentConversationId
237
- ? this.ledger.listByParent(parentConversationId)
238
- : this.ledger.listAll();
263
+ const targetParentId = parentConversationId?.trim();
264
+ const candidateIds = new Set();
265
+ if (targetParentId) {
266
+ // 1. Check parent in store to know parent's workspace
267
+ const parentConv = this.store.conversation(targetParentId);
268
+ if (parentConv) {
269
+ const workspaceChildren = this.store.conversationsForWorkspace(parentConv.workspaceId);
270
+ for (const child of workspaceChildren) {
271
+ if (child.parentConversationId === targetParentId) {
272
+ candidateIds.add(child.id);
273
+ }
274
+ }
275
+ }
276
+ else {
277
+ // Fallback: check all conversations in store
278
+ const allConversations = this.store.snapshot().conversations;
279
+ for (const conv of allConversations) {
280
+ if (conv.parentConversationId === targetParentId) {
281
+ candidateIds.add(conv.id);
282
+ }
283
+ }
284
+ }
285
+ // 2. Also check in-memory ledger entries
286
+ const ledgerEntries = this.ledger.listByParent(targetParentId);
287
+ for (const entry of ledgerEntries) {
288
+ candidateIds.add(entry.conversationId);
289
+ }
290
+ }
291
+ else {
292
+ // No parent specified: list all orchestrator / child sessions
293
+ for (const conv of this.store.snapshot().conversations) {
294
+ if (conv.parentConversationId || conv.origin === "orchestrator") {
295
+ candidateIds.add(conv.id);
296
+ }
297
+ }
298
+ for (const entry of this.ledger.listAll()) {
299
+ candidateIds.add(entry.conversationId);
300
+ }
301
+ }
302
+ const parentConv = targetParentId ? this.store.conversation(targetParentId) : undefined;
239
303
  const summaries = [];
240
- for (const entry of entries) {
304
+ for (const conversationId of candidateIds) {
241
305
  try {
242
- const summary = await this.status(entry.conversationId);
306
+ const summary = await this.status(conversationId);
307
+ // Workspace boundary enforcement: do not leak sessions across different workspaces
308
+ if (parentConv && summary.workspaceId !== parentConv.workspaceId) {
309
+ continue;
310
+ }
311
+ if (targetParentId && summary.parentConversationId !== targetParentId) {
312
+ continue;
313
+ }
243
314
  summaries.push(summary);
244
315
  }
245
316
  catch {
246
317
  // Conversation was removed from store; prune ledger
247
- this.ledger.delete(entry.conversationId);
318
+ this.ledger.delete(conversationId);
248
319
  }
249
320
  }
250
- return summaries;
321
+ // Sort newest first by creation time or updated timestamp
322
+ return summaries.sort((a, b) => {
323
+ const timeA = a.createdAt ?? (a.updatedAt ? new Date(a.updatedAt).getTime() : 0);
324
+ const timeB = b.createdAt ?? (b.updatedAt ? new Date(b.updatedAt).getTime() : 0);
325
+ return timeB - timeA;
326
+ });
251
327
  }
252
328
  /**
253
329
  * Stops a target session.
@@ -169,7 +169,7 @@ export class CommandHandler {
169
169
  return;
170
170
  }
171
171
  if (command.type === "conversation.create") {
172
- return await this.conversationService.createConversation(command.workspaceId);
172
+ return await this.conversationService.createConversation(command.workspaceId, undefined, command.cwd);
173
173
  }
174
174
  if (command.type === "conversation.autoRename") {
175
175
  // Answered on a pushed event rather than through the return value: this spends real model time, and
@@ -0,0 +1,19 @@
1
+ import type { ConversationContextUsage, SubagentUsage } from "./types.js";
2
+ export type TurnUsageReadout = {
3
+ usage?: SubagentUsage;
4
+ costKnown: boolean;
5
+ contextUsage?: ConversationContextUsage;
6
+ };
7
+ /** Collect only completed assistant messages, never the repeated usage on streaming deltas. */
8
+ export declare class ConversationUsageCollector {
9
+ private readonly total;
10
+ private costKnown;
11
+ add(value: unknown): void;
12
+ readout(context: {
13
+ tokens: number | null;
14
+ contextWindow: number;
15
+ } | undefined, model: {
16
+ provider?: string;
17
+ id?: string;
18
+ } | undefined, agentDir: string): TurnUsageReadout;
19
+ }
@@ -0,0 +1,40 @@
1
+ import { effectiveContextWindow, readDeclaredWindows } from "./model-context-window.js";
2
+ const nonnegative = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
3
+ /** Collect only completed assistant messages, never the repeated usage on streaming deltas. */
4
+ export class ConversationUsageCollector {
5
+ total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
6
+ costKnown = true;
7
+ add(value) {
8
+ if (!value || typeof value !== "object") {
9
+ this.costKnown = false;
10
+ return;
11
+ }
12
+ const usage = value;
13
+ const keys = ["input", "output", "cacheRead", "cacheWrite"];
14
+ const cost = usage.cost?.total;
15
+ if (!nonnegative(cost))
16
+ this.costKnown = false;
17
+ if (!keys.some(key => nonnegative(usage[key])) && !nonnegative(cost))
18
+ return;
19
+ for (const key of keys)
20
+ if (nonnegative(usage[key]))
21
+ this.total[key] += usage[key];
22
+ if (nonnegative(cost))
23
+ this.total.cost += cost;
24
+ this.total.turns++;
25
+ }
26
+ readout(context, model, agentDir) {
27
+ let contextUsage;
28
+ if (context) {
29
+ const contextWindow = model?.provider && model.id
30
+ ? effectiveContextWindow(readDeclaredWindows(agentDir), model.provider, model.id, context.contextWindow) ?? context.contextWindow
31
+ : context.contextWindow;
32
+ if (nonnegative(contextWindow) && contextWindow > 0) {
33
+ // Pi returns null after compaction until another assistant supplies a trustworthy token count.
34
+ const tokens = nonnegative(context.tokens) ? context.tokens : null;
35
+ contextUsage = { tokens, contextWindow, percent: tokens === null ? null : tokens / contextWindow * 100 };
36
+ }
37
+ }
38
+ return { ...(this.total.turns ? { usage: { ...this.total } } : {}), costKnown: this.costKnown, ...(contextUsage ? { contextUsage } : {}) };
39
+ }
40
+ }
@@ -35,4 +35,7 @@ export declare function computeFileDiff(options: {
35
35
  preferred?: "auto" | DiffBaselineKind;
36
36
  tracker?: FileChangeTracker;
37
37
  workspaceId?: string;
38
+ /** Repository root and tracker key when `root` is a session subdirectory. */
39
+ gitRoot?: string;
40
+ trackerPath?: string;
38
41
  }): Promise<FileDiff>;
@@ -36,12 +36,12 @@ import { FilesError, resolveInsideRoot } from "./files.js";
36
36
  * they are looking at and it must not silently become a different one.
37
37
  */
38
38
  export async function computeFileDiff(options) {
39
- const { root, path, preferred = "auto", tracker, workspaceId } = options;
39
+ const { root, path, preferred = "auto", tracker, workspaceId, gitRoot = root, trackerPath = path } = options;
40
40
  const after = await readAfter(root, path);
41
41
  // The agent's baseline. It is the RIGHT one whenever it exists, because it is the content the agent
42
42
  // actually started from - which is not necessarily HEAD, and not necessarily what the file looked
43
43
  // like when the server started.
44
- const agentChange = tracker && workspaceId ? tracker.get(workspaceId, path) : undefined;
44
+ const agentChange = tracker && workspaceId ? tracker.get(workspaceId, trackerPath) : undefined;
45
45
  if ((preferred === "auto" || preferred === "agent") && agentChange) {
46
46
  return {
47
47
  path,
@@ -55,8 +55,8 @@ export async function computeFileDiff(options) {
55
55
  };
56
56
  }
57
57
  if (preferred === "auto" || preferred === "git") {
58
- if (await isGitRepository(root)) {
59
- const head = await gitHeadContent(root, path);
58
+ if (await isGitRepository(gitRoot)) {
59
+ const head = await gitHeadContent(gitRoot, trackerPath);
60
60
  if (head !== null) {
61
61
  return { path, baseline: "git", before: head, after, label: "Git 已提交的版本" };
62
62
  }
@@ -10,6 +10,7 @@ export type IndexedFile = {
10
10
  export type FileIndexEvent = {
11
11
  type: "files.index";
12
12
  workspaceId: string;
13
+ cwd?: string;
13
14
  version: number;
14
15
  changed: IndexedFile[];
15
16
  removed: string[];
@@ -33,6 +34,7 @@ export type FileIndexSnapshot = {
33
34
  export declare class FileIndex {
34
35
  readonly workspaceId: string;
35
36
  private readonly broadcast;
37
+ readonly cwd: string;
36
38
  readonly entries: Map<string, IndexedFile>;
37
39
  private readonly history;
38
40
  private root;
@@ -42,7 +44,7 @@ export declare class FileIndex {
42
44
  version: number;
43
45
  builtAt: number;
44
46
  complete: boolean;
45
- constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void);
47
+ constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void, cwd?: string);
46
48
  start(root: string): void;
47
49
  stop(): void;
48
50
  snapshot(since?: number): FileIndexSnapshot;
@@ -83,4 +85,4 @@ export declare class FileIndex {
83
85
  * said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
84
86
  * conversation.
85
87
  */
86
- export declare function refreshWorkspaceIndex(indexes: Map<string, FileIndex> | undefined, workspaceId: string, path: string): void;
88
+ export declare function refreshWorkspaceIndex(indexes: Map<string, FileIndex> | undefined, workspaceId: string, path: string, workspaceRoot?: string): void;
@@ -1,4 +1,5 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
2
3
  import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative, toWorkspaceRelative } from "./files.js";
3
4
  const MAX_DIRECTORIES = 4000;
4
5
  const MAX_FILES = 30000;
@@ -12,6 +13,7 @@ const MAX_MS = 4000;
12
13
  export class FileIndex {
13
14
  workspaceId;
14
15
  broadcast;
16
+ cwd;
15
17
  entries = new Map();
16
18
  history = [];
17
19
  root = "";
@@ -21,9 +23,10 @@ export class FileIndex {
21
23
  version = 0;
22
24
  builtAt = 0;
23
25
  complete = false;
24
- constructor(workspaceId, broadcast) {
26
+ constructor(workspaceId, broadcast, cwd = "") {
25
27
  this.workspaceId = workspaceId;
26
28
  this.broadcast = broadcast;
29
+ this.cwd = cwd;
27
30
  }
28
31
  start(root) {
29
32
  if (this.building || this.builtAt)
@@ -162,7 +165,7 @@ export class FileIndex {
162
165
  if (!changed.length && !removed.length && !force)
163
166
  return;
164
167
  this.version += 1;
165
- const event = { type: "files.index", workspaceId: this.workspaceId, version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
168
+ const event = { type: "files.index", workspaceId: this.workspaceId, ...(this.cwd ? { cwd: this.cwd } : {}), version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
166
169
  this.history.push({ version: this.version, changed, removed });
167
170
  if (this.history.length > 128)
168
171
  this.history.shift();
@@ -185,11 +188,13 @@ export class FileIndex {
185
188
  * said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
186
189
  * conversation.
187
190
  */
188
- export function refreshWorkspaceIndex(indexes, workspaceId, path) {
189
- const index = indexes?.get(workspaceId);
190
- if (!index)
191
- return;
192
- void index.refresh(path).catch((error) => {
193
- console.warn(`Super Pi: the file index did not refresh ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
194
- });
191
+ export function refreshWorkspaceIndex(indexes, workspaceId, path, workspaceRoot) {
192
+ for (const index of indexes?.values() ?? []) {
193
+ if (index.workspaceId !== workspaceId)
194
+ continue;
195
+ const target = workspaceRoot && index.cwd ? resolve(workspaceRoot, path) : path;
196
+ void index.refresh(target).catch((error) => {
197
+ console.warn(`Super Pi: the file index did not refresh ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
198
+ });
199
+ }
195
200
  }
@@ -5,7 +5,9 @@
5
5
  * the agent's writes - go through this one function, so there is exactly one index per workspace and
6
6
  * the two never drift into answering from different maps.
7
7
  */
8
- export declare function ensureNameIndex(indexes: Map<string, FileNameIndex>, workspaceId: string, root: string): FileNameIndex;
8
+ export declare function ensureNameIndex(indexes: Map<string, FileNameIndex>, workspaceId: string, root: string, cwd?: string): FileNameIndex;
9
+ /** Keeps every root/subdirectory index for a workspace in step with one observed write. */
10
+ export declare function recordWorkspacePath(indexes: Map<string, FileNameIndex>, workspaceId: string, workspaceRoot: string, path: string): void;
9
11
  /** What the index knows about one path. Deliberately small: no size, no etag, no mtime. */
10
12
  export type NameEntry = {
11
13
  /** Workspace-relative, `/`-separated. */
@@ -60,6 +62,7 @@ export declare function compareNameHits(a: NameEntry, b: NameEntry, needle: stri
60
62
  */
61
63
  export declare class FileNameIndex {
62
64
  readonly workspaceId: string;
65
+ readonly cwd: string;
63
66
  readonly entries: Map<string, NameEntry>;
64
67
  private root;
65
68
  private building;
@@ -67,7 +70,7 @@ export declare class FileNameIndex {
67
70
  private scanned;
68
71
  private stopped;
69
72
  private incomplete;
70
- constructor(workspaceId: string);
73
+ constructor(workspaceId: string, cwd?: string);
71
74
  /**
72
75
  * Whether the first pass has FINISHED.
73
76
  *
@@ -32,7 +32,9 @@
32
32
  * replaces.
33
33
  */
34
34
  import { readdir } from "node:fs/promises";
35
+ import { isAbsolute, relative, resolve, sep } from "node:path";
35
36
  import { createRootGuard, toRelative } from "./files.js";
37
+ import { sessionScopeKey } from "./session-cwd.js";
36
38
  /**
37
39
  * THE INDEX FOR ONE WORKSPACE, created on demand and then kept.
38
40
  *
@@ -40,15 +42,28 @@ import { createRootGuard, toRelative } from "./files.js";
40
42
  * the agent's writes - go through this one function, so there is exactly one index per workspace and
41
43
  * the two never drift into answering from different maps.
42
44
  */
43
- export function ensureNameIndex(indexes, workspaceId, root) {
44
- let index = indexes.get(workspaceId);
45
+ export function ensureNameIndex(indexes, workspaceId, root, cwd = "") {
46
+ const key = sessionScopeKey(workspaceId, cwd);
47
+ let index = indexes.get(key);
45
48
  if (!index) {
46
- index = new FileNameIndex(workspaceId);
47
- indexes.set(workspaceId, index);
49
+ index = new FileNameIndex(workspaceId, cwd);
50
+ indexes.set(key, index);
48
51
  }
49
52
  index.start(root);
50
53
  return index;
51
54
  }
55
+ /** Keeps every root/subdirectory index for a workspace in step with one observed write. */
56
+ export function recordWorkspacePath(indexes, workspaceId, workspaceRoot, path) {
57
+ const absolute = resolve(workspaceRoot, path);
58
+ for (const index of indexes.values()) {
59
+ if (index.workspaceId !== workspaceId)
60
+ continue;
61
+ const scoped = relative(resolve(workspaceRoot, index.cwd), absolute);
62
+ if (!scoped || scoped === ".." || scoped.startsWith(`..${sep}`) || isAbsolute(scoped))
63
+ continue;
64
+ index.upsert(scoped.split(sep).join("/"));
65
+ }
66
+ }
52
67
  const MAX_DIRECTORIES = 20000;
53
68
  const MAX_FILES = 200000;
54
69
  /**
@@ -92,6 +107,7 @@ function hitPosition(entry, needle) {
92
107
  */
93
108
  export class FileNameIndex {
94
109
  workspaceId;
110
+ cwd;
95
111
  entries = new Map();
96
112
  root = "";
97
113
  building = false;
@@ -99,8 +115,9 @@ export class FileNameIndex {
99
115
  scanned = 0;
100
116
  stopped = false;
101
117
  incomplete = false;
102
- constructor(workspaceId) {
118
+ constructor(workspaceId, cwd = "") {
103
119
  this.workspaceId = workspaceId;
120
+ this.cwd = cwd;
104
121
  }
105
122
  /**
106
123
  * Whether the first pass has FINISHED.
@@ -13,8 +13,8 @@ export type GitFile = {
13
13
  name: string;
14
14
  xy: string;
15
15
  };
16
- export declare function gitStatus(root: string): Promise<GitWorkspace>;
17
- export declare function gitLog(root: string, limit: number): Promise<{
16
+ export declare function gitStatus(root: string, cwd?: string): Promise<GitWorkspace>;
17
+ export declare function gitLog(root: string, limit: number, cwd?: string): Promise<{
18
18
  commits: {
19
19
  hash: string;
20
20
  short: string;
@@ -138,4 +138,4 @@ export type GitBlameOptions = {
138
138
  export declare function gitBlame(root: string, path: string, options?: GitBlameOptions): Promise<GitBlameResponse>;
139
139
  /** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
140
140
  export declare function isGitRepository(root: string): Promise<boolean>;
141
- export declare function gitMutate(root: string, op: string, path?: string, message?: string): Promise<GitWorkspace>;
141
+ export declare function gitMutate(root: string, op: string, path?: string, message?: string, cwd?: string): Promise<GitWorkspace>;
@@ -5,8 +5,9 @@ import { existsSync } from "node:fs";
5
5
  import { readFile, stat } from "node:fs/promises";
6
6
  const exec = promisify(execFile);
7
7
  async function git(cwd, args, options) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: options?.maxBuffer ?? 8 * 1024 * 1024 }); }
8
- function files(raw) { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
9
- const xy = line.slice(0, 2), path = line.slice(3).split("\0")[0].replaceAll("\\", "/");
8
+ function files(raw, cwd = "") { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
9
+ const xy = line.slice(0, 2), repoPath = line.slice(3).split("\0")[0].replaceAll("\\", "/");
10
+ const path = cwd && repoPath.startsWith(`${cwd}/`) ? repoPath.slice(cwd.length + 1) : repoPath;
10
11
  const item = { path, name: basename(path), xy };
11
12
  if (xy === "??")
12
13
  untracked.push(item);
@@ -17,14 +18,14 @@ function files(raw) { const staged = [], unstaged = [], untracked = []; for (con
17
18
  unstaged.push(item);
18
19
  }
19
20
  } return { staged, unstaged, untracked }; }
20
- export async function gitStatus(root) { try {
21
+ export async function gitStatus(root, cwd = "") { try {
21
22
  await git(root, ["rev-parse", "--git-dir"]);
22
23
  }
23
24
  catch {
24
25
  throw Object.assign(new Error("此工作区不是 Git 仓库"), { code: "not_a_repo" });
25
- } const status = await git(root, ["status", "--porcelain=v1", "-z"]); const branch = await git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).then(r => r.stdout.trim() || null).catch(() => null); const unborn = !(await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false)); const ahead = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(a) ? a : null; }).catch(() => null); const behind = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(b) ? b : null; }).catch(() => null); return { branch, detached: !branch, unborn, ahead, behind, ...files(status.stdout) }; }
26
- export async function gitLog(root, limit) { const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false); if (!hasHead)
27
- return { commits: [], truncated: false }; const out = await git(root, ["log", `-${Math.min(Math.max(limit, 1), 50)}`, "--format=%H%x00%h%x00%s%x00%an%x00%aI%x00"]); const parts = out.stdout.split("\0").filter(Boolean); const commits = []; for (let i = 0; i + 4 < parts.length; i += 5)
26
+ } const status = await git(root, ["status", "--porcelain=v1", "-z", ...(cwd ? ["--", cwd] : [])]); const branch = await git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).then(r => r.stdout.trim() || null).catch(() => null); const unborn = !(await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false)); const ahead = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(a) ? a : null; }).catch(() => null); const behind = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(b) ? b : null; }).catch(() => null); return { branch, detached: !branch, unborn, ahead, behind, ...files(status.stdout, cwd) }; }
27
+ export async function gitLog(root, limit, cwd = "") { const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false); if (!hasHead)
28
+ return { commits: [], truncated: false }; const out = await git(root, ["log", `-${Math.min(Math.max(limit, 1), 50)}`, "--format=%H%x00%h%x00%s%x00%an%x00%aI%x00", ...(cwd ? ["--", cwd] : [])]); const parts = out.stdout.split("\0").filter(Boolean); const commits = []; for (let i = 0; i + 4 < parts.length; i += 5)
28
29
  commits.push({ hash: parts[i], short: parts[i + 1], subject: parts[i + 2], author: parts[i + 3], date: new Date(parts[i + 4]).toISOString() }); return { commits, truncated: commits.length >= limit }; }
29
30
  export async function gitDiff(root, path, staged) { const safe = resolve(root, path); if (!safe.startsWith(resolve(root) + sep) || !existsSync(safe))
30
31
  throw Object.assign(new Error("文件路径无效"), { code: "path" }); const check = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false); if (!check)
@@ -341,7 +342,7 @@ function safePath(root, path) {
341
342
  throw Object.assign(new Error("文件路径无效"), { code: "path" });
342
343
  return path;
343
344
  }
344
- export async function gitMutate(root, op, path, message) {
345
+ export async function gitMutate(root, op, path, message, cwd = "") {
345
346
  if (op === "stage" || op === "unstage" || op === "discard") {
346
347
  if (!path)
347
348
  throw Object.assign(new Error("缺少文件路径"), { code: "path" });
@@ -356,7 +357,7 @@ export async function gitMutate(root, op, path, message) {
356
357
  else if (op === "commit") {
357
358
  if (!message?.trim())
358
359
  throw Object.assign(new Error("提交消息不能为空"), { code: "bad_message" });
359
- await git(root, ["commit", "-m", message.trim()]);
360
+ await git(root, ["commit", "-m", message.trim(), ...(cwd ? ["--", cwd] : [])]);
360
361
  }
361
362
  else if (op === "pull")
362
363
  await git(root, ["pull", "--ff-only"]);
@@ -364,5 +365,5 @@ export async function gitMutate(root, op, path, message) {
364
365
  await git(root, ["push"]);
365
366
  else
366
367
  throw Object.assign(new Error("未知 Git 操作"), { code: "bad_op" });
367
- return gitStatus(root);
368
+ return gitStatus(root, cwd);
368
369
  }