pi-onedev-toolkit 0.2.2 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0 — 2026-09-05
4
+
5
+ ### Changed
6
+
7
+ - `issue`, `pull`, and `build` `list` actions with an explicit `project` now work even when the current repository has no OneDev remote: the query runs against a temporary scratch remote that is created and removed for the call.
8
+ - Refreshed development dependencies (pi packages 0.85.1, typebox 1.3.27).
9
+
10
+
11
+ ## 0.3.0 — 2026-09-05
12
+
13
+ ### Changed
14
+
15
+ - `issue`, `pull`, and `build` `list` actions now return a one-line-per-item digest instead of raw JSON (raw list items embed full Markdown descriptions); pass `full: true` for the raw payload. Digests fetch internally up to 512 KB; larger or non-JSON payloads fall back to the normal bounded raw output.
16
+ - Query parameter descriptions now include common OneDev query examples (`open`, `submitted by "user"`, `Assignees is "user"`, `order by`), so most queries no longer need the 8–11 KB grammar dump from `query_description`.
17
+ - `onedev_context` gains `list_projects` to discover accessible projects without activating a domain tool; it works from any repository once TOD is configured.
18
+
19
+ ### Fixed
20
+
21
+ - One-shot watch notifications can no longer be lost when the emission consumer throws.
22
+ - Watch polls record `lastError` (visible in `onedev_watch list`) instead of failing silently, and clear it on recovery.
23
+ - Pull-attention watches read state only from top-level fields, so a nested build status can no longer masquerade as the PR state and cause spurious or missed wakes.
24
+ - Watch snapshot calls are bounded to 10 seconds so one slow TOD call cannot stall other watches.
4
25
 
5
26
  ## 0.2.2 — 2026-09-04
6
27
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ When the current directory is a OneDev repository, the status line shows the act
28
28
 
29
29
  | Tool | Domains | Covers |
30
30
  | --- | --- | --- |
31
- | `onedev_context` (always active) | — | Readiness, authenticated health, login, and setup guidance |
31
+ | `onedev_context` (always active) | — | Readiness, authenticated health, login, setup guidance, and accessible project discovery |
32
32
  | `onedev_tools` (always active) | — | Activate only the issue, pull, build, and watch domains needed now |
33
33
  | `onedev_issue` | issue | Query/get/comment issues, change fields/labels/state, log work, issue branches |
34
34
  | `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, reply/resolve review comments, approve, request changes, merge/discard |
@@ -37,7 +37,7 @@ When the current directory is a OneDev repository, the status line shows the act
37
37
 
38
38
  Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list and system prompt light. Activation performs the first authenticated check and returns the server, project, and login context in the same response.
39
39
 
40
- Model-visible issue, PR, and build output defaults to 16 KB. PR patches, build logs, and build-spec schemas default to 32 KB. Use `max_bytes` to raise or lower a call's budget up to 128 KB; truncation keeps the payload head/tail and is reported explicitly. Internal watch polling remains separately bounded and is never copied into the model context.
40
+ Model-visible issue, PR, and build output defaults to 16 KB. PR patches, build logs, and build-spec schemas default to 32 KB. Use `max_bytes` to raise or lower a call's budget up to 128 KB; truncation keeps the payload head/tail and is reported explicitly. List actions instead return one-line-per-item digests by default (pass `full` for the raw payload), so bulk queries stay small without losing items; payloads beyond the 512 KB internal fetch fall back to the normal bounded raw output. Internal watch polling remains separately bounded and is never copied into the model context.
41
41
 
42
42
  Remote and local mutations use Pi's real interactive approval UI. An approval can apply once, for the session, or persist for the exact server/project/operation in `~/.pi/agent/onedev-toolkit.json`; remove that file to revoke all saved approvals. Non-interactive sessions reject unapproved mutations. `build run` in local mode uses an isolated temporary Git index so TOD cannot leave the user's index staged; `check_spec` is treated as a mutation because TOD may upgrade `.onedev-buildspec.yml`.
43
43
 
@@ -55,6 +55,7 @@ Attachment download is intentionally not exposed: current TOD releases may send
55
55
 
56
56
  - Update TOD regularly from its upstream install script. `get_unit_test_report` reports an actionable upgrade error when the installed TOD predates that command.
57
57
  - Plain references require the current repository to have a OneDev remote from which TOD can infer the project. The toolkit reports `no_project` rather than pretending a qualified reference can bypass TOD's resolver.
58
+ - `issue`, `pull`, and `build` `list` calls with an explicit `project` work from any repository: the toolkit binds TOD to a throwaway Git remote for the query and removes it afterwards.
58
59
  - Tool calls use `snake_case`, for example `for_code_review`, `target_branch`, `report_name`, and `interval_seconds`.
59
60
 
60
61
  ## Skills
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onedev-toolkit",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Lightweight OneDev integration for Pi via TOD: repo-aware issue, pull request, build, and watch tools",
5
5
  "type": "module",
6
6
  "author": {
@@ -53,12 +53,12 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "0.85.0",
57
- "@earendil-works/pi-server": "0.85.0",
58
- "@earendil-works/pi-coding-agent": "0.85.0",
56
+ "@earendil-works/pi-ai": "0.85.1",
57
+ "@earendil-works/pi-coding-agent": "0.85.1",
58
+ "@earendil-works/pi-server": "0.85.1",
59
59
  "@types/node": "^22.19.0",
60
60
  "@types/proper-lockfile": "4.1.4",
61
- "typebox": "1.3.25",
61
+ "typebox": "1.3.27",
62
62
  "typescript": "^7.0.2",
63
63
  "vitest": "^4.1.10"
64
64
  },
@@ -19,6 +19,7 @@ export interface OneDevWatchInfo {
19
19
  intervalSeconds: number;
20
20
  expiresAt: string;
21
21
  failures: number;
22
+ lastError?: string;
22
23
  }
23
24
 
24
25
  export interface AddOneDevWatchOptions {
@@ -62,6 +63,7 @@ interface WatchRecord {
62
63
  expiresAt: number;
63
64
  nextPollAt: number;
64
65
  failures: number;
66
+ lastError?: string;
65
67
  baseline: WatchSnapshot;
66
68
  }
67
69
 
@@ -77,6 +79,8 @@ const MAX_INTERVAL_SECONDS = 300;
77
79
  const MAX_TTL_MINUTES = 24 * 60;
78
80
  const MAX_WATCHES = 10;
79
81
  const MAX_BACKOFF_MS = 5 * 60_000;
82
+
83
+ const SNAPSHOT_TIMEOUT_MS = 10_000;
80
84
  const TERMINAL_BUILD_STATUSES = new Set([
81
85
  "SUCCESSFUL",
82
86
  "FAILED",
@@ -163,6 +167,33 @@ function firstBoolean(value: JsonValue, keys: readonly string[]): boolean | unde
163
167
  return found;
164
168
  }
165
169
 
170
+ function topLevelString(
171
+ value: JsonValue,
172
+ keys: readonly string[],
173
+ ): string | undefined {
174
+ if (typeof value !== "object" || value === null || Array.isArray(value))
175
+ return undefined;
176
+ for (const key of keys) {
177
+ const child = value[key];
178
+ if (typeof child === "string" && child.trim() !== "") return child.trim();
179
+ if (typeof child === "number") return String(child);
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function topLevelBoolean(
185
+ value: JsonValue,
186
+ keys: readonly string[],
187
+ ): boolean | undefined {
188
+ if (typeof value !== "object" || value === null || Array.isArray(value))
189
+ return undefined;
190
+ for (const key of keys) {
191
+ const child = value[key];
192
+ if (typeof child === "boolean") return child;
193
+ }
194
+ return undefined;
195
+ }
196
+
166
197
  function unresolvedCodeComments(
167
198
  value: JsonValue,
168
199
  ): Array<{ id: string | number; replies: number }> {
@@ -335,6 +366,7 @@ export class OneDevSourceWatchManager {
335
366
  intervalSeconds: record.intervalMs / 1_000,
336
367
  expiresAt: new Date(record.expiresAt).toISOString(),
337
368
  failures: record.failures,
369
+ lastError: record.lastError,
338
370
  };
339
371
  }
340
372
 
@@ -353,6 +385,7 @@ export class OneDevSourceWatchManager {
353
385
  const output = await runTod(this.deps.exec, ["build", "get", ref], {
354
386
  cwd: context.cwd,
355
387
  signal,
388
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
356
389
  maxOutputBytes: 512_000,
357
390
  });
358
391
  const value = json(output.text, `build ${ref}`);
@@ -368,16 +401,19 @@ export class OneDevSourceWatchManager {
368
401
  runTod(this.deps.exec, ["pr", "get", ref], {
369
402
  cwd: context.cwd,
370
403
  signal,
404
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
371
405
  maxOutputBytes: 512_000,
372
406
  }),
373
407
  runTod(this.deps.exec, ["pr", "get-code-comments", ref], {
374
408
  cwd: context.cwd,
375
409
  signal,
410
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
376
411
  maxOutputBytes: 512_000,
377
412
  }),
378
413
  runTod(this.deps.exec, ["pr", "get-builds", ref], {
379
414
  cwd: context.cwd,
380
415
  signal,
416
+ timeoutMs: SNAPSHOT_TIMEOUT_MS,
381
417
  maxOutputBytes: 512_000,
382
418
  }),
383
419
  ]);
@@ -397,17 +433,16 @@ export class OneDevSourceWatchManager {
397
433
  "requestedChanges",
398
434
  ]);
399
435
  const selected = {
400
- state: firstString(pullValue, ["status", "state"]) ?? null,
436
+ state: topLevelString(pullValue, ["status", "state"]) ?? null,
401
437
  head:
402
- firstString(pullValue, [
403
- "headCommitHash",
404
- "sourceCommitHash",
405
- "headCommit",
406
- "sourceCommit",
438
+ topLevelString(pullValue, [
439
+ "headCommitHash",
440
+ "sourceCommitHash",
441
+ "headCommit",
442
+ "sourceCommit",
407
443
  ]) ?? null,
408
- mergeable: firstBoolean(pullValue, ["mergeable"]) ?? null,
409
- conflicted:
410
- firstBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
444
+ mergeable: topLevelBoolean(pullValue, ["mergeable"]) ?? null,
445
+ conflicted: topLevelBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
411
446
  unresolved,
412
447
  reviews,
413
448
  buildSignal,
@@ -496,21 +531,28 @@ export class OneDevSourceWatchManager {
496
531
  if (generation !== this.#generation || !this.#records.has(record.id)) break;
497
532
  if (this.#changed(record, snapshot)) {
498
533
  this.#records.delete(record.id);
499
- this.emit(this.#emission(record, snapshot));
534
+ try {
535
+ this.emit(this.#emission(record, snapshot));
536
+ } catch {
537
+ // a throwing consumer must not re-enter the poll-failure path
538
+ }
500
539
  continue;
501
540
  }
502
541
  record.failures = 0;
542
+ record.lastError = undefined;
503
543
  record.baseline = snapshot;
504
544
  record.nextPollAt = this.now() + record.intervalMs;
505
- } catch {
506
- record.failures += 1;
507
- record.nextPollAt =
508
- this.now() +
509
- Math.min(
510
- record.intervalMs * 2 ** Math.min(record.failures, 6),
511
- MAX_BACKOFF_MS,
512
- );
513
- }
545
+ } catch (error) {
546
+ record.failures += 1;
547
+ record.lastError =
548
+ error instanceof Error ? error.message : String(error);
549
+ record.nextPollAt =
550
+ this.now() +
551
+ Math.min(
552
+ record.intervalMs * 2 ** Math.min(record.failures, 6),
553
+ MAX_BACKOFF_MS,
554
+ );
555
+ }
514
556
  if (record.expiresAt <= now) this.#records.delete(record.id);
515
557
  }
516
558
  } finally {
@@ -7,6 +7,7 @@ import { Type, type Static } from "typebox";
7
7
  import {
8
8
  confirmMutation,
9
9
  DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
10
+ digestBuilds,
10
11
  modelOutputBytes,
11
12
  modelOutputLimit,
12
13
  pushRepeated,
@@ -14,6 +15,7 @@ import {
14
15
  REF_DESCRIPTION,
15
16
  requireParam,
16
17
  runInContext,
18
+ runListInContext,
17
19
  toolResult,
18
20
  withFooter,
19
21
  type ToolDeps,
@@ -62,9 +64,24 @@ export interface BuildParams {
62
64
  query?: string;
63
65
  count?: number;
64
66
  offset?: number;
67
+ full?: boolean;
65
68
  max_bytes?: number;
66
69
  }
67
70
 
71
+ /**
72
+ * Local unless another mode or an explicit branch/tag is given. Names the
73
+ * non-obvious defaulting rule shared by the arg builder and the isolated
74
+ * index path; both call sites must resolve it identically.
75
+ */
76
+ function isLocalRunMode(
77
+ params: Pick<BuildParams, "mode" | "branch" | "tag">,
78
+ ): boolean {
79
+ return (
80
+ params.mode === "local" ||
81
+ (params.mode === undefined && !params.branch && !params.tag)
82
+ );
83
+ }
84
+
68
85
  export function buildBuildArgs(params: BuildParams): string[] {
69
86
  const args: string[] = ["build"];
70
87
  const action = requireParam(params.action, "build", "action");
@@ -116,10 +133,7 @@ export function buildBuildArgs(params: BuildParams): string[] {
116
133
  if (params.mode === "tag" && params.branch !== undefined) {
117
134
  throw new Error("tag run mode does not accept branch");
118
135
  }
119
- if (
120
- params.mode === "local" ||
121
- (params.mode === undefined && !params.branch && !params.tag)
122
- ) {
136
+ if (isLocalRunMode(params)) {
123
137
  args.push("--local");
124
138
  } else if (params.mode === "branch" || params.branch !== undefined) {
125
139
  args.push("--branch", requireParam(params.branch, action, "branch"));
@@ -225,6 +239,9 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
225
239
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
226
240
  count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
227
241
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
242
+ full: Type.Optional(
243
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
244
+ ),
228
245
  max_bytes: modelOutputBytes(),
229
246
  }, { additionalProperties: false });
230
247
  type Params = Static<typeof parameters>;
@@ -253,12 +270,17 @@ export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void {
253
270
  });
254
271
  }
255
272
  // ponytail: build run streams the full log; preserve bounded metadata and the failure tail
256
- const isLocalRun =
257
- isRun &&
258
- (params.mode === "local" ||
259
- (params.mode === undefined && !params.branch && !params.tag));
273
+ const isLocalRun = isRun && isLocalRunMode(params);
260
274
  let output: TodOutput;
261
- if (isLocalRun) {
275
+ if (params.action === "list") {
276
+ output = await runListInContext(deps, args, {
277
+ signal,
278
+ project: params.project,
279
+ full: params.full,
280
+ maxOutputBytes: params.max_bytes,
281
+ digest: digestBuilds,
282
+ });
283
+ } else if (isLocalRun) {
262
284
  output = await runLocalBuildWithIsolatedIndex(
263
285
  deps,
264
286
  args,
@@ -1,3 +1,6 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
5
  import { Type } from "typebox";
3
6
  import type {
@@ -11,6 +14,7 @@ import type {
11
14
  import {
12
15
  TodError,
13
16
  runTod,
17
+ truncateMiddle,
14
18
  type CommandExecutor,
15
19
  type TodOutput,
16
20
  } from "../tod.js";
@@ -34,7 +38,7 @@ export const REF_DESCRIPTION =
34
38
  export const FIELDS_DESCRIPTION =
35
39
  '"key=value" assignments; use valid_fields to discover values.';
36
40
  export const QUERY_DESCRIPTION =
37
- "OneDev query; use query_description for syntax.";
41
+ 'OneDev query; e.g. \'open\', \'submitted by "user"\', \'Assignees is "user"\', \'order by "Submit Date" desc\'; full syntax: query_description.';
38
42
 
39
43
  export function modelOutputBytes() {
40
44
  return Type.Optional(
@@ -110,6 +114,308 @@ export function pushRepeated(
110
114
  for (const value of values ?? []) args.push(flag, value);
111
115
  }
112
116
 
117
+ /**
118
+ * One-line-per-item digests for list results. Raw OneDev list payloads embed
119
+ * full Markdown descriptions (~2 KB per item); digests keep bulk queries in a
120
+ * few KB so pagination stays lossless. Digests return undefined when the
121
+ * payload is not a JSON object array so callers fall back to the raw text.
122
+ */
123
+ export const DIGEST_FETCH_BYTES = 512_000;
124
+
125
+ type DigestItem = Record<string, unknown>;
126
+
127
+ function parseDigestItems(text: string): DigestItem[] | undefined {
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(text);
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ if (!Array.isArray(parsed)) return undefined;
135
+ return parsed.every(
136
+ (item) => typeof item === "object" && item !== null && !Array.isArray(item),
137
+ )
138
+ ? (parsed as DigestItem[])
139
+ : undefined;
140
+ }
141
+
142
+ function stringAt(
143
+ item: DigestItem,
144
+ keys: readonly string[],
145
+ ): string | undefined {
146
+ for (const key of keys) {
147
+ const value = item[key];
148
+ if (typeof value === "number") return String(value);
149
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ function listAt(item: DigestItem, keys: readonly string[]): string[] {
155
+ for (const key of keys) {
156
+ const value = item[key];
157
+ if (Array.isArray(value) && value.length > 0) return value.map(String);
158
+ }
159
+ return [];
160
+ }
161
+
162
+ function clip(value: string, maxChars: number): string {
163
+ if (value.length <= maxChars) return value;
164
+ let end = maxChars - 1;
165
+ const previous = value.charCodeAt(end - 1);
166
+ if (previous >= 0xd800 && previous <= 0xdbff) end -= 1;
167
+ return `${value.slice(0, Math.max(end, 0))}…`;
168
+ }
169
+
170
+ function day(value: string | undefined): string | undefined {
171
+ return value === undefined ? undefined : value.slice(0, 10);
172
+ }
173
+
174
+ function digestLine(
175
+ ref: string,
176
+ tags: ReadonlyArray<string | undefined>,
177
+ summary: string,
178
+ trailer: ReadonlyArray<string | undefined>,
179
+ ): string {
180
+ const one = (value: string): string => value.replace(/\s+/g, " ");
181
+ const bracketed = tags
182
+ .filter((tag): tag is string => tag !== undefined)
183
+ .map(one);
184
+ const filled = trailer
185
+ .filter((part): part is string => part !== undefined)
186
+ .map(one);
187
+ const label = one(ref);
188
+ const head =
189
+ bracketed.length > 0 ? `${label} [${bracketed.join(" ")}]` : label;
190
+ const text = one(summary);
191
+ return `${head}${text !== "" ? ` ${text}` : ""}${filled.length > 0 ? ` · ${filled.join(" · ")}` : ""}`;
192
+ }
193
+
194
+ export function digestIssues(text: string): string | undefined {
195
+ const items = parseDigestItems(text);
196
+ if (!items) return undefined;
197
+ if (items.length === 0) return "(no issues)";
198
+ return items
199
+ .map((item) => {
200
+ const assignees = listAt(item, ["Assignees"]);
201
+ return digestLine(
202
+ stringAt(item, ["reference", "number"]) ?? "#?",
203
+ [
204
+ stringAt(item, ["state", "status"]),
205
+ ...listAt(item, ["Priority"]),
206
+ ...listAt(item, ["Type"]),
207
+ ],
208
+ clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
209
+ [
210
+ stringAt(item, ["submitter"]),
211
+ day(stringAt(item, ["submitDate"])),
212
+ assignees.length > 0
213
+ ? `assigned: ${assignees.join(", ")}`
214
+ : undefined,
215
+ ],
216
+ );
217
+ })
218
+ .join("\n");
219
+ }
220
+
221
+ export function digestPulls(text: string): string | undefined {
222
+ const items = parseDigestItems(text);
223
+ if (!items) return undefined;
224
+ if (items.length === 0) return "(no pull requests)";
225
+ return items
226
+ .map((item) => {
227
+ const flow = [
228
+ stringAt(item, ["sourceBranch"]),
229
+ stringAt(item, ["targetBranch"]),
230
+ ]
231
+ .filter((branch): branch is string => branch !== undefined)
232
+ .join("→");
233
+ return digestLine(
234
+ stringAt(item, ["reference", "number"]) ?? "#?",
235
+ [stringAt(item, ["status", "state"])],
236
+ clip(stringAt(item, ["title"]) ?? "(untitled)", 120),
237
+ [
238
+ flow !== "" ? flow : undefined,
239
+ stringAt(item, ["submitter"]),
240
+ day(stringAt(item, ["submitDate"])),
241
+ ],
242
+ );
243
+ })
244
+ .join("\n");
245
+ }
246
+
247
+ export function digestBuilds(text: string): string | undefined {
248
+ const items = parseDigestItems(text);
249
+ if (!items) return undefined;
250
+ if (items.length === 0) return "(no builds)";
251
+ return items
252
+ .map((item) =>
253
+ digestLine(
254
+ stringAt(item, ["reference", "number"]) ?? "#?",
255
+ [stringAt(item, ["status"])],
256
+ clip(stringAt(item, ["jobName"]) ?? "(unknown job)", 80),
257
+ [
258
+ stringAt(item, ["refName"])?.replace("refs/heads/", ""),
259
+ stringAt(item, ["agent", "submitter"]),
260
+ day(stringAt(item, ["submitDate"])),
261
+ ],
262
+ ),
263
+ )
264
+ .join("\n");
265
+ }
266
+
267
+ export function digestProjects(text: string): string | undefined {
268
+ const items = parseDigestItems(text);
269
+ if (!items) return undefined;
270
+ if (items.length === 0) return "(no projects)";
271
+ return items
272
+ .map((item) => {
273
+ const features = [
274
+ item.codeManagement === true ? "code" : undefined,
275
+ item.issueManagement === true ? "issues" : undefined,
276
+ ].filter((feature): feature is string => feature !== undefined);
277
+ return digestLine(
278
+ stringAt(item, ["path"]) ?? "#?",
279
+ [],
280
+ clip(
281
+ stringAt(item, ["description"]) ?? stringAt(item, ["name"]) ?? "",
282
+ 80,
283
+ ),
284
+ [features.length > 0 ? `[${features.join("+")}]` : undefined],
285
+ );
286
+ })
287
+ .join("\n");
288
+ }
289
+
290
+ /**
291
+ * Run tod inside a throwaway Git repository whose only remote points at the
292
+ * requested OneDev project. TOD refuses `--project` queries when the working
293
+ * directory has no OneDev remote; the scratch repository supplies one
294
+ * without touching the user's checkout.
295
+ */
296
+ async function runTodWithScratchRemote(
297
+ exec: CommandExecutor,
298
+ args: readonly string[],
299
+ serverUrl: string,
300
+ project: string,
301
+ options: { signal?: AbortSignal; maxOutputBytes: number },
302
+ ): Promise<TodOutput> {
303
+ const directory = await mkdtemp(join(tmpdir(), "pi-onedev-scratch-"));
304
+ try {
305
+ for (const gitArgs of [
306
+ ["init", "--quiet", directory],
307
+ [
308
+ "remote",
309
+ "add",
310
+ "origin",
311
+ `${serverUrl.replace(/\/$/, "")}/${project}.git`,
312
+ ],
313
+ ]) {
314
+ const done = await exec("git", gitArgs, { cwd: directory });
315
+ if (done.code !== 0 || done.killed) {
316
+ throw new TodError(
317
+ `could not prepare a temporary OneDev project context: ${(done.stderr || done.stdout).trim() || `git ${gitArgs[0] ?? ""} failed`}`,
318
+ );
319
+ }
320
+ }
321
+ return await runTod(exec, [...args, "--working-dir", directory], {
322
+ signal: options.signal,
323
+ maxOutputBytes: options.maxOutputBytes,
324
+ });
325
+ } finally {
326
+ await rm(directory, { recursive: true, force: true });
327
+ }
328
+ }
329
+
330
+ async function finishListOutput(
331
+ fetch: (maxOutputBytes: number) => Promise<TodOutput>,
332
+ options: {
333
+ full?: boolean;
334
+ maxOutputBytes?: number;
335
+ digest: (text: string) => string | undefined;
336
+ },
337
+ ): Promise<TodOutput> {
338
+ if (options.full === true) {
339
+ return fetch(modelOutputLimit(options.maxOutputBytes));
340
+ }
341
+ const output = await fetch(DIGEST_FETCH_BYTES);
342
+ const limit = modelOutputLimit(options.maxOutputBytes);
343
+ const digested = options.digest(output.text);
344
+ if (
345
+ digested !== undefined &&
346
+ Buffer.byteLength(digested, "utf8") <= limit
347
+ ) {
348
+ return {
349
+ text: digested,
350
+ truncated: false,
351
+ originalBytes: output.originalBytes,
352
+ };
353
+ }
354
+ // no digest, or one that outgrew the budget: the final model-visible
355
+ // text is still bounded by the normal output limit
356
+ const bounded = truncateMiddle(digested ?? output.text, limit);
357
+ return {
358
+ text: bounded.text,
359
+ truncated: bounded.truncated || output.truncated,
360
+ originalBytes: output.originalBytes,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Fetch a whole bounded list payload, then reduce it to a digest unless the
366
+ * caller opted into raw output. The internal fetch budget exceeds the
367
+ * model-visible cap on purpose: the digest, not the payload, reaches the
368
+ * model. Without an inferred project, an explicit `project` list query runs
369
+ * against a temporary scratch remote instead of failing.
370
+ */
371
+ export async function runListInContext(
372
+ deps: ToolDeps,
373
+ args: readonly string[],
374
+ options: {
375
+ signal?: AbortSignal;
376
+ full?: boolean;
377
+ maxOutputBytes?: number;
378
+ project?: string;
379
+ digest: (text: string) => string | undefined;
380
+ },
381
+ ): Promise<TodOutput> {
382
+ const context = deps.context();
383
+ if (context.status !== "ready" || !context.project) {
384
+ const scratchServer = context.serverUrl;
385
+ const scratchProject = options.project;
386
+ if (
387
+ context.status !== "no_project" ||
388
+ scratchServer === undefined ||
389
+ scratchProject === undefined
390
+ ) {
391
+ throw new TodError(
392
+ context.problem ??
393
+ "OneDev tools require a repository whose project can be inferred from a OneDev remote",
394
+ );
395
+ }
396
+ return finishListOutput(
397
+ (maxOutputBytes) =>
398
+ runTodWithScratchRemote(
399
+ deps.exec,
400
+ args,
401
+ scratchServer,
402
+ scratchProject,
403
+ { signal: options.signal, maxOutputBytes },
404
+ ),
405
+ options,
406
+ );
407
+ }
408
+ return finishListOutput(
409
+ (maxOutputBytes) =>
410
+ runTod(deps.exec, args, {
411
+ cwd: context.cwd,
412
+ signal: options.signal,
413
+ maxOutputBytes,
414
+ }),
415
+ options,
416
+ );
417
+ }
418
+
113
419
  export function confirmMutation(
114
420
  deps: ToolDeps,
115
421
  ctx: ExtensionContext,
@@ -2,8 +2,15 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
4
  import { formatContext, setupGuidance } from "../context.js";
5
+ import { runTod } from "../tod.js";
5
6
  import type { OneDevSourceWatchManager } from "../source-watch.js";
6
- import { toolResult, type ToolDeps } from "./common.js";
7
+ import {
8
+ DIGEST_FETCH_BYTES,
9
+ digestProjects,
10
+ toolResult,
11
+ withFooter,
12
+ type ToolDeps,
13
+ } from "./common.js";
7
14
  import { registerBuildTool } from "./build.js";
8
15
  import { registerIssueTool } from "./issue.js";
9
16
  import { registerPullTool } from "./pull.js";
@@ -46,7 +53,17 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
46
53
  description: "Inspect OneDev readiness, health, login, or setup.",
47
54
  parameters: Type.Object(
48
55
  {
49
- action: StringEnum(["current", "health", "login_name", "setup"]),
56
+ action: StringEnum([
57
+ "current",
58
+ "health",
59
+ "login_name",
60
+ "setup",
61
+ "list_projects",
62
+ ]),
63
+ count: Type.Optional(
64
+ Type.Integer({ minimum: 1, maximum: 100, description: "Default: 25" }),
65
+ ),
66
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
50
67
  },
51
68
  { additionalProperties: false },
52
69
  ),
@@ -57,6 +74,31 @@ function registerContextTool(pi: ExtensionAPI, deps: ToolDeps): void {
57
74
  if (params.action === "setup") {
58
75
  return toolResult(setupGuidance(deps.context()));
59
76
  }
77
+ if (params.action === "list_projects") {
78
+ const context = deps.context();
79
+ if (
80
+ context.status === "not_configured" ||
81
+ context.status === "unavailable"
82
+ ) {
83
+ return toolResult(setupGuidance(context));
84
+ }
85
+ const args = ["project", "list", "--count", String(params.count ?? 25)];
86
+ if (params.offset !== undefined)
87
+ args.push("--offset", String(params.offset));
88
+ const output = await runTod(deps.exec, args, {
89
+ cwd: context.cwd,
90
+ signal,
91
+ maxOutputBytes: DIGEST_FETCH_BYTES,
92
+ });
93
+ const digested = digestProjects(output.text);
94
+ return toolResult(
95
+ withFooter(
96
+ digested ?? output.text,
97
+ context,
98
+ digested === undefined && output.truncated,
99
+ ),
100
+ );
101
+ }
60
102
  const context = await deps.refreshContext({
61
103
  verifyAuth: true,
62
104
  force: true,
@@ -4,12 +4,14 @@ import { Type, type Static } from "typebox";
4
4
  import {
5
5
  FIELDS_DESCRIPTION,
6
6
  confirmMutation,
7
+ digestIssues,
7
8
  modelOutputBytes,
8
9
  pushRepeated,
9
10
  QUERY_DESCRIPTION,
10
11
  REF_DESCRIPTION,
11
12
  requireParam,
12
13
  runInContext,
14
+ runListInContext,
13
15
  toolResult,
14
16
  withFooter,
15
17
  type ToolDeps,
@@ -66,6 +68,7 @@ export interface IssueParams {
66
68
  query?: string;
67
69
  count?: number;
68
70
  offset?: number;
71
+ full?: boolean;
69
72
  max_bytes?: number;
70
73
  }
71
74
 
@@ -200,6 +203,9 @@ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
200
203
  }),
201
204
  ),
202
205
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
206
+ full: Type.Optional(
207
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
208
+ ),
203
209
  max_bytes: modelOutputBytes(),
204
210
  }, { additionalProperties: false });
205
211
  type Params = Static<typeof parameters>;
@@ -223,10 +229,19 @@ export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void {
223
229
  signal,
224
230
  });
225
231
  }
226
- const output = await runInContext(deps, args, {
227
- signal,
228
- maxOutputBytes: params.max_bytes,
229
- });
232
+ const output =
233
+ params.action === "list"
234
+ ? await runListInContext(deps, args, {
235
+ signal,
236
+ project: params.project,
237
+ full: params.full,
238
+ maxOutputBytes: params.max_bytes,
239
+ digest: digestIssues,
240
+ })
241
+ : await runInContext(deps, args, {
242
+ signal,
243
+ maxOutputBytes: params.max_bytes,
244
+ });
230
245
  return toolResult(
231
246
  withFooter(output.text, deps.context(), output.truncated),
232
247
  {
package/src/tools/pull.ts CHANGED
@@ -4,12 +4,14 @@ import { Type, type Static } from "typebox";
4
4
  import {
5
5
  confirmMutation,
6
6
  DEFAULT_LARGE_MODEL_OUTPUT_BYTES,
7
+ digestPulls,
7
8
  modelOutputBytes,
8
9
  pushRepeated,
9
10
  QUERY_DESCRIPTION,
10
11
  REF_DESCRIPTION,
11
12
  requireParam,
12
13
  runInContext,
14
+ runListInContext,
13
15
  toolResult,
14
16
  withFooter,
15
17
  type ToolDeps,
@@ -88,6 +90,7 @@ export interface PullParams {
88
90
  query?: string;
89
91
  count?: number;
90
92
  offset?: number;
93
+ full?: boolean;
91
94
  max_bytes?: number;
92
95
  }
93
96
 
@@ -274,6 +277,9 @@ export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
274
277
  query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })),
275
278
  count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
276
279
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
280
+ full: Type.Optional(
281
+ Type.Boolean({ description: "Raw JSON instead of digest" }),
282
+ ),
277
283
  max_bytes: modelOutputBytes(),
278
284
  }, { additionalProperties: false });
279
285
  type Params = Static<typeof parameters>;
@@ -298,14 +304,23 @@ export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void {
298
304
  signal,
299
305
  });
300
306
  }
301
- const output = await runInContext(deps, args, {
302
- signal,
303
- maxOutputBytes:
304
- params.max_bytes ??
305
- (params.action === "get_patch"
306
- ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES
307
- : undefined),
308
- });
307
+ const output =
308
+ params.action === "list"
309
+ ? await runListInContext(deps, args, {
310
+ signal,
311
+ project: params.project,
312
+ full: params.full,
313
+ maxOutputBytes: params.max_bytes,
314
+ digest: digestPulls,
315
+ })
316
+ : await runInContext(deps, args, {
317
+ signal,
318
+ maxOutputBytes:
319
+ params.max_bytes ??
320
+ (params.action === "get_patch"
321
+ ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES
322
+ : undefined),
323
+ });
309
324
  return toolResult(
310
325
  withFooter(output.text, deps.context(), output.truncated),
311
326
  {