feat(coding-agent): list updated packages with version changes in pi update (#11707)

## Problem

`pi update`, `pi update --all` and `pi update --extensions` print only
`Updating <source>...` progress lines and a final `Updated packages`.
Users cannot see which extensions changed or to which version. Several
outdated npm packages in the same scope are installed as one batch, so
the progress output does not even name them.

## Change

`pi update` now prints a summary of what actually changed:

```
Updated 2 packages:
  npm:foo 1.2.0 -> 1.3.0
  git:github.com/user/bar (project) abc1234 -> def5678
```

When nothing changed it prints `Packages are already up to date` (or
`<source> is already up to date`).

- npm: compares the installed `package.json` version before and after
the batch install. The "before" version comes from the existing
`shouldUpdateNpmSource` check, so this adds no extra registry calls.
- git: compares `git rev-parse HEAD` before and after `updateGit`.
Commits are shortened to 7 characters.
- Packages whose version or commit did not change are omitted, including
npm installs that hit stale registry metadata (those already get the
existing warning).

## API

`PackageManager.update()` still returns `Promise<void>`.
`ProgressEvent.type` gains a `"result"` member. Each changed package is
reported through the existing progress callback as:

```ts
{ type: "result", action: "update", source: "npm:foo", message: "npm:foo 1.2.0 -> 1.3.0" }
```

The CLI collects these messages to print the summary. A separate type
keeps the existing contract that each `start` is followed by exactly one
`complete` or `error` for the same source. Reusing `complete` would have
produced a second `complete` for git packages and a `complete` without a
`start` for batched npm packages. The only compatibility cost is for
TypeScript consumers with an exhaustive `switch` on `event.type`.

## Tests

- `test/package-manager.test.ts`: npm update emits `start`, `complete`,
then a `result` with `npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0`; an
up-to-date package emits no result.
- `test/git-update.test.ts`: git update emits a result with the old and
new commit; an up-to-date clone emits no result.

`should expand ~ in exclusion and exact patterns` fails locally on
Windows. It tests path resolution and is unrelated to this change.
This commit is contained in:
Petr Houška 2026-11-02 21:21:23 +01:00 • committed by GitHub
parent 15f00a786a
commit 342514bdf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 10 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707))
## [1.5.0] - 2026-11-02
### New Features

View file

@ -108,7 +108,13 @@ export interface PackageInstallFailure {
export type MissingSourceAction = "install" | "skip" | "error";
export interface ProgressEvent {
type: "start" | "progress" | "complete" | "error";
/**
* `start` is followed by exactly one `complete` or `error` for the same source.
* `progress` carries an intermediate message or warning.
* `result` reports an outcome outside that pairing, e.g. one changed package
* after update(): `npm:foo 1.2.0 -> 1.3.0`.
*/
type: "start" | "progress" | "complete" | "error" | "result";
action: "install" | "remove" | "update" | "clone" | "pull";
source: string;
message?: string;
@ -193,6 +199,15 @@ interface GitUpdateTarget extends ConfiguredUpdateSource {
parsed: GitSource;
}
/** A configured package whose installed npm version or git commit changed during update(). */
interface UpdatedPackage extends ConfiguredUpdateSource {
type: "npm" | "git";
/** npm version or git commit before the update; undefined if it was not installed. */
from?: string;
/** npm version or git commit after the update. */
to?: string;
}
interface ResourceAccumulator {
extensions: Map<string, { metadata: PathMetadata; enabled: boolean }>;
skills: Map<string, { metadata: PathMetadata; enabled: boolean }>;
@ -1204,10 +1219,8 @@ export class DefaultPackageManager implements PackageManager {
const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);
const userNpmUpdates: NpmUpdateTarget[] = [];
const projectNpmUpdates: NpmUpdateTarget[] = [];
for (const result of npmCheckResults) {
if (!result.shouldUpdate) {
continue;
}
const npmUpdates = npmCheckResults.filter((result) => result.shouldUpdate);
for (const result of npmUpdates) {
if (result.entry.scope === "user") {
userNpmUpdates.push(result.entry);
} else {
@ -1222,17 +1235,64 @@ export class DefaultPackageManager implements PackageManager {
if (projectNpmUpdates.length > 0) {
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
}
const gitUpdates: UpdatedPackage[] = [];
if (gitCandidates.length > 0) {
const gitTasks = gitCandidates.map(
(entry) => async () =>
this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => {
const targetDir = this.getGitInstallPath(entry.parsed, entry.scope);
const from = await this.getGitHead(targetDir);
await this.updateGit(entry.parsed, entry.scope);
const to = await this.getGitHead(targetDir);
if (from !== to) {
gitUpdates.push({ source: entry.source, type: "git", scope: entry.scope, from, to });
}
}),
);
tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {}));
}
await Promise.all(tasks);
const updated: UpdatedPackage[] = [];
for (const { entry } of npmUpdates) {
const installedPath = this.getManagedNpmInstallPath(entry.parsed, entry.scope);
const to = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
if (to !== entry.installedVersion) {
updated.push({ source: entry.source, type: "npm", scope: entry.scope, from: entry.installedVersion, to });
}
}
// Keep git results in configured order, independent of completion order.
for (const entry of gitCandidates) {
const update = gitUpdates.find((u) => u.source === entry.source && u.scope === entry.scope);
if (update) updated.push(update);
}
// Report each changed package, e.g. "npm:foo 1.2.0 -> 1.3.0".
for (const pkg of updated) {
const format = (value: string | undefined) =>
value === undefined ? "(none)" : pkg.type === "git" ? value.slice(0, 7) : value;
const scope = pkg.scope === "project" ? " (project)" : "";
this.emitProgress({
type: "result",
action: "update",
source: pkg.source,
message: `${pkg.source}${scope} ${format(pkg.from)} -> ${format(pkg.to)}`,
});
}
}
private async getGitHead(targetDir: string): Promise<string | undefined> {
if (!existsSync(targetDir)) return undefined;
try {
const head = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
return head.trim() || undefined;
} catch {
return undefined;
}
}
private async shouldUpdateNpmSource(

View file

@ -1027,8 +1027,12 @@ export async function handlePackageCommand(
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
// Update result events describe a changed package, e.g. "npm:foo 1.2.0 -> 1.3.0".
const updatedPackages: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
if (event.type === "result" && event.action === "update" && event.message) {
updatedPackages.push(event.message);
} else if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
} else if (event.type === "progress" && event.message) {
process.stdout.write(chalk.yellow(`${event.message}\n`));
@ -1099,10 +1103,18 @@ export async function handlePackageCommand(
if (updateTargetIncludesExtensions(target)) {
const updateSource = target.type === "extensions" ? target.source : undefined;
await packageManager.update(updateSource);
if (updateSource) {
console.log(chalk.green(`Updated ${updateSource}`));
if (updatedPackages.length === 0) {
console.log(
chalk.green(
updateSource ? `${updateSource} is already up to date` : "Packages are already up to date",
),
);
} else {
console.log(chalk.green("Updated packages"));
const count = updatedPackages.length;
console.log(chalk.green(`Updated ${count} package${count === 1 ? "" : "s"}:`));
for (const message of updatedPackages) {
console.log(` ${message}`);
}
}
}
if (updateTargetIncludesSelf(target)) {

View file

@ -150,8 +150,13 @@ describe("DefaultPackageManager git update", () => {
}
};
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
expect(updated).toEqual([]);
expect(executedCommands).toContain(
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
);
@ -165,14 +170,20 @@ describe("DefaultPackageManager git update", () => {
it("should update to latest commit when remote has new commits", async () => {
setupRemoteAndInstall();
expect(getFileContent(installedDir, "extension.ts")).toBe("// v1");
const oldCommit = getCurrentCommit(installedDir);
// Add a new commit to remote
const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
// Update via package manager (no args = uses settings)
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
// Verify update succeeded
expect(updated).toEqual([`${gitSource} ${oldCommit.slice(0, 7)} -> ${newCommit.slice(0, 7)}`]);
expect(getCurrentCommit(installedDir)).toBe(newCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});

View file

@ -2612,10 +2612,21 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.2.0"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockImplementation(async () => {
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.0" }));
});
const events: ProgressEvent[] = [];
packageManager.setProgressCallback((event) => events.push(event));
await packageManager.update("npm:example");
// The result is a separate event, so each start still has exactly one complete.
expect(events.map((event) => [event.type, event.source, event.message])).toEqual([
["start", "npm:example@^1.0.0", "Updating npm:example@^1.0.0..."],
["complete", "npm:example@^1.0.0", undefined],
["result", "npm:example@^1.0.0", "npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0"],
]);
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example@^1.0.0", "version", "--json"],
@ -2647,8 +2658,14 @@ export default function(api) { api.registerTool({ name: "test", description: "te
.mockResolvedValue('["1.0.0","1.3.1","1.0.2"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update("npm:example");
expect(updated).toEqual([]);
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example@^1.0.0", "version", "--json"],