@danypops/pi-lector 0.11.2 → 0.12.0

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
@@ -20,9 +20,10 @@ it's still building, instead of requiring an explicit "start indexing" call.
20
20
  When a session starts inside a Git repository, pi-lector checks a durable, bounded
21
21
  source-content manifest without blocking startup. The footer reports not cached,
22
22
  caching, or cached across every workspace touched so far this session; completion
23
- also emits a one-shot notification. Session shutdown stops polling, and the agent
24
- receives each state transition once in its context.
23
+ also emits a one-shot notification. The adapter subscribes to daemon job completion,
24
+ keeps bounded status polling as the disconnect fallback, and closes both on session shutdown.
25
+ The agent receives each state transition once in its context.
25
26
 
26
27
  For an explicit, custom-bound population outside of Pi (a larger scan than the
27
28
  default 500 files / 100 symbols per file), use `lector workspace populate-symbol-graph`
28
- and `lector job status` directly.
29
+ and `lector job wait` directly.
@@ -132,6 +132,7 @@ import {
132
132
  createWorkspaceCacheOperations,
133
133
  describeCacheState,
134
134
  monitorWorkspaceCache,
135
+ waitForJobCompletion,
135
136
  } from "./workspace-cache/operations.ts";
136
137
  import { formatJobSnapshotResult, formatWorkspaceCacheCall, formatWorkspaceCacheStatusResult } from "./workspace-cache/rendering.ts";
137
138
  import { createLectorWriteOperations } from "./write/operations.ts";
@@ -1108,7 +1109,7 @@ export default function (pi: ExtensionAPI) {
1108
1109
  });
1109
1110
 
1110
1111
  interface WorkspaceCacheToolDetails {
1111
- readonly action: "status" | "populate" | "job_status";
1112
+ readonly action: "status" | "populate" | "wait" | "job_status";
1112
1113
  readonly status?: WorkspaceCacheStatus;
1113
1114
  readonly job?: JobSnapshot<PopulateSymbolGraphResult>;
1114
1115
  }
@@ -1117,14 +1118,14 @@ export default function (pi: ExtensionAPI) {
1117
1118
  name: "workspace_cache",
1118
1119
  label: "Workspace Cache",
1119
1120
  description:
1120
- "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting any work. action=populate explicitly requests a scan (optionally larger than the default 500-file/100-symbol auto-scan every workspace gets on first touch) and waits up to waitMs for it to finish, returning a job snapshot either way. action=job_status polls a job returned by populate that didn't finish within its own wait.",
1121
+ "Checks or drives population of the workspace's persisted symbol graph -- the store reachable_from, symbol_annotations (anchor resolution), reference_based_rename, and workspace_map all read from, separate from the live language-server index find_symbols/hover/go_to_definition use. action=status reports not-cached/caching/partial/cached for the given bounds, without starting work. action=populate requests a scan and briefly waits for fast completion. action=wait subscribes to daemon job completion, with bounded status polling only when push delivery is unavailable. action=job_status is a point-in-time diagnostic read.",
1121
1122
  promptSnippet: "Check or force-populate the workspace's persisted symbol graph",
1122
1123
  promptGuidelines: [
1123
1124
  "Use action=populate with a larger maxFiles/maxSymbolsPerFile before relying on reachable_from/symbol_annotations/reference_based_rename against a workspace bigger than the default 500-file auto-scan -- their own errors (empty results, UnknownAnnotationAnchor, ReferenceBasedRenameRequiresFreshGraph) usually mean the graph never reached the files you need, not that population is simply still catching up.",
1124
- "action=populate returns immediately once its own waitMs elapses even if the job is still running -- check the returned job's status and poll with action=job_status (the same jobId) rather than assuming a non-succeeded result means failure.",
1125
+ "When action=populate returns a queued/running job, call action=wait once with that jobId; do not run shell sleep or manually poll job_status.",
1125
1126
  ],
1126
1127
  parameters: Type.Object({
1127
- action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("job_status")]),
1128
+ action: Type.Union([Type.Literal("status"), Type.Literal("populate"), Type.Literal("wait"), Type.Literal("job_status")]),
1128
1129
  directory: Type.Optional(
1129
1130
  Type.String({ description: "Required for action=status/populate -- absolute or cwd-relative path used to resolve the workspace" }),
1130
1131
  ),
@@ -1137,17 +1138,33 @@ export default function (pi: ExtensionAPI) {
1137
1138
  waitMs: Type.Optional(
1138
1139
  Type.Number({
1139
1140
  description:
1140
- "action=populate only -- how long to wait for the job to finish before returning its current snapshot; defaults to 3000, capped by the daemon at 30000",
1141
+ "action=populate: initial daemon wait, defaults to 3000 and capped at 30000. action=wait: total subscription/fallback bound, defaults to 300000 and capped at 300000",
1141
1142
  }),
1142
1143
  ),
1143
- jobId: Type.Optional(Type.String({ description: "Required for action=job_status -- a jobId returned by a prior action=populate call" })),
1144
+ jobId: Type.Optional(Type.String({ description: "Required for action=wait/job_status -- a jobId returned by action=populate" })),
1144
1145
  }),
1145
- async execute(_toolCallId, params): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1146
+ async execute(_toolCallId, params, signal): Promise<AgentToolResult<WorkspaceCacheToolDetails>> {
1146
1147
  if (params.action === "job_status") {
1147
1148
  if (!params.jobId) throw new Error("workspace_cache action=job_status requires jobId");
1148
- const job = await codeIntelligenceOperations.jobStatus(params.jobId);
1149
+ const job = await cacheOperations.jobStatus(params.jobId);
1149
1150
  return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "job_status", job } };
1150
1151
  }
1152
+ if (params.action === "wait") {
1153
+ if (!params.jobId) throw new Error("workspace_cache action=wait requires jobId");
1154
+ const waitMs = params.waitMs ?? 300_000;
1155
+ if (!Number.isSafeInteger(waitMs) || waitMs < 1 || waitMs > 300_000)
1156
+ throw new Error("workspace_cache action=wait waitMs must be an integer from 1 to 300000");
1157
+ const pollIntervalMs = 5_000;
1158
+ const completed = await waitForJobCompletion(cacheOperations, params.jobId, {
1159
+ pollIntervalMs,
1160
+ maxPolls: Math.ceil(waitMs / pollIntervalMs),
1161
+ shouldContinue: () => !signal?.aborted,
1162
+ signal,
1163
+ });
1164
+ if (signal?.aborted) throw new DOMException("workspace cache wait canceled", "AbortError");
1165
+ const job = completed ?? (await cacheOperations.jobStatus(params.jobId));
1166
+ return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "wait", job } };
1167
+ }
1151
1168
  if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
1152
1169
  const directory = resolve(cwd, params.directory);
1153
1170
  const maxFiles = params.maxFiles ?? 500;
@@ -1156,11 +1173,11 @@ export default function (pi: ExtensionAPI) {
1156
1173
  const status = await cacheOperations.status(directory, maxFiles, maxSymbolsPerFile);
1157
1174
  return { content: [{ type: "text", text: JSON.stringify(status) }], details: { action: "status", status } };
1158
1175
  }
1159
- const job = await codeIntelligenceOperations.populateSymbolGraph(directory, maxFiles, maxSymbolsPerFile, params.waitMs ?? 3_000);
1176
+ const job = await cacheOperations.submit(directory, maxFiles, maxSymbolsPerFile, params.waitMs ?? 3_000);
1160
1177
  return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "populate", job } };
1161
1178
  },
1162
1179
  renderCall(args, theme, context) {
1163
- const action = args.action === "populate" || args.action === "job_status" ? args.action : "status";
1180
+ const action = args.action === "populate" || args.action === "wait" || args.action === "job_status" ? args.action : "status";
1164
1181
  const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
1165
1182
  text.setText(formatWorkspaceCacheCall(action, args, theme));
1166
1183
  return text;
@@ -1,10 +1,18 @@
1
- import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
1
+ import { type JobSnapshot, type PopulateSymbolGraphResult, resolveLectorDaemonConnection, type WorkspaceCacheStatus } from "@danypops/lector";
2
+ import { connectPushChannel } from "@danypops/vehicle-client/daemon-client";
2
3
  import { lectorClient, withWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
3
4
 
5
+ export interface JobWatchHandle {
6
+ close(): void;
7
+ }
8
+
9
+ export type JobWatchOutcome = { readonly status: "subscribed"; readonly handle: JobWatchHandle } | { readonly status: "unavailable" };
10
+
4
11
  export interface WorkspaceCacheOperations {
5
12
  status(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<WorkspaceCacheStatus>;
6
- submit(directory: string, maxFiles: number, maxSymbolsPerFile: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
13
+ submit(directory: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
7
14
  jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
15
+ watchJob?(jobId: string, onJob: (job: JobSnapshot<PopulateSymbolGraphResult>) => void): Promise<JobWatchOutcome>;
8
16
  }
9
17
 
10
18
  export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
@@ -18,7 +26,7 @@ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
18
26
  },
19
27
  );
20
28
  },
21
- submit(directory, maxFiles, maxSymbolsPerFile) {
29
+ submit(directory, maxFiles, maxSymbolsPerFile, waitMs = 0) {
22
30
  return withWorkspace(
23
31
  () => workspaceForProjectDirectory(directory),
24
32
  async ({ workspaceId }) => {
@@ -26,7 +34,7 @@ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
26
34
  const { job } = await client.callOnce("job.submit", {
27
35
  operation: "workspace.populateSymbolGraph",
28
36
  input: { workspaceId, maxFiles, maxSymbolsPerFile },
29
- waitMs: 0,
37
+ waitMs,
30
38
  });
31
39
  return job;
32
40
  },
@@ -37,6 +45,33 @@ export function createWorkspaceCacheOperations(): WorkspaceCacheOperations {
37
45
  const { job } = await client.call("job.status", { jobId });
38
46
  return job;
39
47
  },
48
+ async watchJob(jobId, onJob) {
49
+ try {
50
+ const client = await lectorClient();
51
+ const { topic } = await client.call("job.watch", { jobId });
52
+ const initialTarget = resolveLectorDaemonConnection();
53
+ const channel = connectPushChannel({
54
+ url: () => {
55
+ const target = resolveLectorDaemonConnection();
56
+ return `ws://${target.host}:${target.port}/push`;
57
+ },
58
+ token: initialTarget.token,
59
+ topics: [topic],
60
+ onMessage(receivedTopic) {
61
+ if (receivedTopic !== topic) return;
62
+ void client
63
+ .call("job.status", { jobId })
64
+ .then(({ job }) => onJob(job))
65
+ .catch(() => {
66
+ // The bounded status cadence remains authoritative when push refresh fails.
67
+ });
68
+ },
69
+ });
70
+ return { status: "subscribed", handle: { close: () => channel.close() } };
71
+ } catch {
72
+ return { status: "unavailable" };
73
+ }
74
+ },
40
75
  };
41
76
  }
42
77
 
@@ -74,6 +109,49 @@ export interface MonitorWorkspaceCacheOptions {
74
109
  readonly sleep?: (ms: number) => Promise<void>;
75
110
  }
76
111
 
112
+ export interface WaitForJobCompletionOptions {
113
+ readonly pollIntervalMs: number;
114
+ readonly maxPolls: number;
115
+ readonly shouldContinue: () => boolean;
116
+ readonly signal?: AbortSignal;
117
+ readonly sleep?: (ms: number) => Promise<void>;
118
+ }
119
+
120
+ /** Waits on Vehicle push delivery and checks status on a bounded cadence when push is unavailable or disconnected. */
121
+ export async function waitForJobCompletion(
122
+ operations: WorkspaceCacheOperations,
123
+ jobId: string,
124
+ options: WaitForJobCompletionOptions,
125
+ ): Promise<JobSnapshot<PopulateSymbolGraphResult> | undefined> {
126
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
127
+ let pushedResolve: ((job: JobSnapshot<PopulateSymbolGraphResult>) => void) | undefined;
128
+ const pushed = new Promise<JobSnapshot<PopulateSymbolGraphResult>>((resolve) => {
129
+ pushedResolve = resolve;
130
+ });
131
+ let watch: JobWatchHandle | undefined;
132
+ let resolveAbort!: () => void;
133
+ const aborted = new Promise<void>((resolve) => {
134
+ resolveAbort = resolve;
135
+ });
136
+ const onAbort = () => resolveAbort();
137
+ options.signal?.addEventListener("abort", onAbort, { once: true });
138
+ try {
139
+ const outcome = await operations.watchJob?.(jobId, (job) => pushedResolve?.(job));
140
+ if (outcome?.status === "subscribed") watch = outcome.handle;
141
+ for (let poll = 0; poll < options.maxPolls && options.shouldContinue() && !options.signal?.aborted; poll++) {
142
+ const current = await operations.jobStatus(jobId);
143
+ if (current.status === "succeeded" || current.status === "failed") return current;
144
+ const next = await Promise.race([pushed, sleep(options.pollIntervalMs).then(() => undefined), aborted.then(() => undefined)]);
145
+ if (next?.status === "succeeded" || next?.status === "failed") return next;
146
+ }
147
+ if (options.shouldContinue() && !options.signal?.aborted) return operations.jobStatus(jobId);
148
+ return undefined;
149
+ } finally {
150
+ options.signal?.removeEventListener("abort", onAbort);
151
+ watch?.close();
152
+ }
153
+ }
154
+
77
155
  /** Drives one bounded session cache lifecycle; Pi event handlers only render its states. */
78
156
  export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations, options: MonitorWorkspaceCacheOptions): Promise<void> {
79
157
  const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
@@ -108,14 +186,12 @@ export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations
108
186
  }
109
187
  options.onState({ status: "caching", jobId });
110
188
 
111
- for (let poll = 0; poll < options.maxPolls && options.shouldContinue(); poll++) {
112
- await sleep(options.pollIntervalMs);
113
- if (!options.shouldContinue()) return;
114
- const job = await operations.jobStatus(jobId);
115
- if (job.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
116
- if (job.status === "succeeded") {
117
- reportCompleted(job);
118
- return;
119
- }
120
- }
189
+ const job = await waitForJobCompletion(operations, jobId, {
190
+ pollIntervalMs: options.pollIntervalMs,
191
+ maxPolls: options.maxPolls,
192
+ shouldContinue: options.shouldContinue,
193
+ sleep,
194
+ });
195
+ if (job?.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
196
+ if (job?.status === "succeeded") reportCompleted(job);
121
197
  }
@@ -1,7 +1,7 @@
1
1
  import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
2
2
  import type { LectorTheme } from "../lector-tui-theme.ts";
3
3
 
4
- type WorkspaceCacheAction = "status" | "populate" | "job_status";
4
+ type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status";
5
5
 
6
6
  export function formatWorkspaceCacheCall(
7
7
  action: WorkspaceCacheAction,
@@ -9,9 +9,9 @@ export function formatWorkspaceCacheCall(
9
9
  theme: LectorTheme,
10
10
  ): string {
11
11
  const label = theme.fg("toolTitle", theme.bold("workspace_cache"));
12
- if (action === "job_status") {
12
+ if (action === "job_status" || action === "wait") {
13
13
  const jobId = typeof args.jobId === "string" ? args.jobId : "";
14
- return `${label} ${theme.fg("accent", "job_status")} ${theme.fg("dim", jobId)}`;
14
+ return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", jobId)}`;
15
15
  }
16
16
  const directory = typeof args.directory === "string" ? args.directory : "";
17
17
  const maxFiles = typeof args.maxFiles === "number" ? String(args.maxFiles) : "default";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.2.0",
22
- "@danypops/lector": "^0.15.0",
22
+ "@danypops/lector": "^0.16.0",
23
23
  "malevich-tui-components": "^0.19.0"
24
24
  },
25
25
  "devDependencies": {