Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ba85073
feat(coding-agent): add namespaced todo projections
panosAthDBX Jul 24, 2026
d2b3a7e
Merge remote-tracking branch 'upstream/main' into feat/namespaced-tod…
panosAthDBX Jul 24, 2026
82937b7
fix(coding-agent): harden todo projection rendering
panosAthDBX Jul 24, 2026
2c4eb4b
Merge upstream main into feat/namespaced-todo-projection
panosAthDBX Jul 25, 2026
2fa6faf
fix(coding-agent): format todo projection changes
panosAthDBX Jul 25, 2026
71a5b82
fix(coding-agent): stabilize todo projection lifecycle
panosAthDBX Jul 25, 2026
218a311
fix(coding-agent): retarget todos with focused session
panosAthDBX Jul 25, 2026
50b2066
fix(coding-agent): render startup todo projections
panosAthDBX Jul 25, 2026
149e84c
fix(coding-agent): refresh projections in mode startup
panosAthDBX Jul 25, 2026
ab8b97d
fix(coding-agent): forward todo projection RPC events
panosAthDBX Jul 25, 2026
aa06c10
fix(coding-agent): publish RPC todo projection snapshots
panosAthDBX Jul 25, 2026
89be10e
Merge upstream main into feat/namespaced-todo-projection
panosAthDBX Jul 25, 2026
023d674
fix(ai): avoid Anthropic OAuth export cycle
panosAthDBX Jul 25, 2026
8b3efca
fix(coding-agent): repair projection transport regressions
panosAthDBX Jul 25, 2026
12d1272
fix(coding-agent): preserve projection contracts
panosAthDBX Jul 25, 2026
90f5be7
fix(acp): retain projection terminal states
panosAthDBX Jul 25, 2026
4d112b1
Merge upstream main into feat/namespaced-todo-projection
panosAthDBX Jul 28, 2026
4e7f108
test: stabilize loaded CI lifecycle checks
panosAthDBX Jul 28, 2026
9bce746
ci: retry transient bun downloads
panosAthDBX Jul 28, 2026
e60f8c6
test: stabilize detached AWS and model probes
panosAthDBX Jul 28, 2026
d2fb602
fix: preserve startup projections and stabilize scrollback test
panosAthDBX Jul 28, 2026
8998366
fix(coding-agent): flush passive RPC startup projections
panosAthDBX Jul 29, 2026
34a75e9
fix(coding-agent): flush passive RPC startup projection after initial…
panosAthDBX Jul 29, 2026
a9143e5
fix(todo): harden sparse projections and RPC startup negotiation
panosAthDBX Jul 29, 2026
fa5fee6
fix(rpc): make startup projection negotiation deterministic
panosAthDBX Jul 30, 2026
1a21cf2
fix(tui): shorten projected home paths and rebuild PR install natives
panosAthDBX Jul 30, 2026
c65715f
test(coding-agent): bound and reap profile CLI probes
panosAthDBX Jul 30, 2026
c0ff0db
Merge upstream main into feat/namespaced-todo-projection
panosAthDBX Aug 5, 2026
6f7abe5
Merge remote-tracking branch 'upstream/main' into feat/namespaced-tod…
panosAthDBX Aug 5, 2026
19500e5
merge: update namespaced todo projections with main
panosAthDBX Aug 5, 2026
11c8777
Merge concurrent PR publication
panosAthDBX Aug 5, 2026
3fe3470
fix(coding-agent): flush startup JSON events before prompting
panosAthDBX Aug 5, 2026
01d3412
test(coding-agent): provide projection snapshot in executor session d…
panosAthDBX Aug 5, 2026
6d76c3a
ci: retry transient browser relay timeout
panosAthDBX Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/bun-install/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ runs:
shell: bash
run: |
export BUN_INSTALL="${HOME}/.bun"
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
echo "${BUN_INSTALL}/bin" >> "$GITHUB_PATH"

# Off-infra (GitHub-hosted): actions/cache for the bun store, split into
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -441,8 +441,12 @@ jobs:
with:
targets: linux-x64-baseline linux-x64-modern
- name: Install method smoke tests
# PR fan-out uses the latest released addon, which can legitimately
# trail the checkout package version. Compiled installs enforce the
# release sentinel, so rebuild the host addon from this checkout.
# Main artifacts are built from the checkout and can be reused.
env:
OMP_INSTALL_TEST_SKIP_NATIVE_BUILD: "1"
OMP_INSTALL_TEST_SKIP_NATIVE_BUILD: ${{ github.event_name != 'pull_request' && '1' || '0' }}
run: bun run ci:test:install-methods

# Aggregates every validation job so publish-side release jobs gate on one
Expand Down
29 changes: 29 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Core methods:
- `getSessionName`, `setSessionName`
- `setModel`, `getThinkingLevel`, `setThinkingLevel`
- `getServiceTiers`, `setServiceTier`
- `setTodoProjection(namespace, phases)`
- `registerProvider`
- `events` (shared event bus)

Expand All @@ -137,6 +138,34 @@ Also exposed:
- `pi.typebox` (zod-backed compatibility shim for legacy TypeBox-style schemas)
- `pi.pi` (package exports)

### Derived todo projections

`pi.setTodoProjection(namespace, phases)` replaces the current session's
display-only projection for one extension-owned namespace. Pass `undefined` to
remove that namespace. Phase and task IDs must be non-empty and stable; task
status is one of `pending`, `in_progress`, `completed`, `failed`, `cancelled`,
or `abandoned`.

```ts
pi.on("session_start", () => {
pi.setTodoProjection("deployments", [{
id: "release",
name: "Release",
tasks: [
{ id: "build", content: "Build artifacts", status: "completed" },
{ id: "publish", content: "Publish package", status: "in_progress" },
],
}]);
});
```

Namespaces coexist and render deterministically after native todos. Projection
input is cloned and limited to public display fields. It is never persisted as
a native todo, sent to the model, normalized to one active item, or included in
todo reminders. Reconstruct derived state from `session_start`,
`session_switch`, `session_branch`, and `session_tree`; clear it with
`pi.setTodoProjection(namespace, undefined)` during extension teardown.

### Message delivery semantics

`pi.sendMessage(message, options)` supports:
Expand Down
1 change: 1 addition & 0 deletions docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ Common event types:
- `model_changed`, `thinking_level_changed`
- `ttsr_triggered`
- `todo_reminder`, `todo_auto_clear`
- `todo_projection_changed` with a defensive `projections` snapshot containing every namespaced extension projection; startup projections are emitted after `ready`
- `irc_message`, `notice`, `goal_updated`

Extension runner errors are emitted separately as:
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/src/providers/aws-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
}
})();
inflight.set(cacheKey, promise);
// A caller may abort its race while this detached resolution keeps running.
// Explicitly own a later rejection so an abandoned shared promise cannot
// surface as an unhandled rejection; active waiters still observe `promise`.
void promise.catch(() => {});
return raceWithSignal(promise, opts.signal);
}

Expand Down
32 changes: 26 additions & 6 deletions packages/ai/test/aws-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,33 @@ describe("resolveAwsCredentials", () => {
await expect(resolveAwsCredentials({ profile: "failing" })).rejects.toThrow(/exited 7.*auth helper broke/);
});

test("aborts a long-running helper when the caller's signal fires", async () => {
const script = await writeFixture("hang.js", `setTimeout(()=>{},60_000);`);
await writeConfig("hangs", `credential_process = ${quoteForConfig(process.execPath)} ${quoteForConfig(script)}`);
test("keeps shared resolution owned after its caller aborts", async () => {
await writeConfig("other", "");
Bun.env.AWS_EC2_METADATA_DISABLED = "false";
const tokenGate = Promise.withResolvers<Response>();
let requestCount = 0;
const fetchImpl = async (..._args: Parameters<typeof fetch>): Promise<Response> => {
requestCount += 1;
if (requestCount === 1) return tokenGate.promise;
if (requestCount === 2) return new Response("test-role");
return Response.json({
AccessKeyId: "AKIADELAYED",
SecretAccessKey: "secret",
Token: "token",
Expiration: "2099-01-01T00:00:00Z",
});
};

const ctrl = new AbortController();
const promise = resolveAwsCredentials({ profile: "hangs", signal: ctrl.signal });
setTimeout(() => ctrl.abort(new Error("test abort")), 50);
await expect(promise).rejects.toBeDefined();
const aborted = resolveAwsCredentials({ profile: "missing", signal: ctrl.signal, fetch: fetchImpl });
ctrl.abort(new Error("test abort"));
await expect(aborted).rejects.toBeDefined();

const completed = resolveAwsCredentials({ profile: "missing", fetch: fetchImpl });
tokenGate.resolve(new Response("imds-token"));
const creds = await completed;
expect(creds.accessKeyId).toBe("AKIADELAYED");
expect(requestCount).toBe(3);
});

test("resolves ECS container credentials with the authorization token", async () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/ai/test/oauth-barrel-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ import { describe, expect, it } from "bun:test";
const STATIC_IMPORT_FIXTURE = `${import.meta.dir}/fixtures/oauth-barrel-import.ts`;

describe("OAuth barrel imports", () => {
// This is a cold subprocess import of the package root. Under the package's
// parallel=8 CI load it can exceed Bun's 5s unit-test default.
it("loads with the Anthropic provider and auth storage while preserving public exports", async () => {
const child = Bun.spawn([process.execPath, STATIC_IMPORT_FIXTURE], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
try {
const [exitCode, stderr] = await Promise.all([
child.exited,
new Response(child.stderr).text(),
new Response(child.stdout).text(),
]);

expect(exitCode, stderr).toBe(0);
expect(exitCode, stderr).toBe(0);
} finally {
if (child.exitCode === null) {
child.kill("SIGKILL");
await child.exited;
}
}
}, 60_000);
});
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@

### Added

- Added the `setTodoProjection()` coding-agent extension API for namespaced, display-only lifecycle progress that stays isolated from native session todos and transcript state; RPC clients receive defensive projection snapshots, including projections published by `session_start` handlers ([#6522](https://github.com/can1357/oh-my-pi/pull/6522) by [@panosAthDBX](https://github.com/panosAthDBX)).
- `omp usage` now surfaces auto-disabled credentials as red `✗` tombstone rows (identity, how long ago, the shortened upstream cause — e.g. `Refresh token expired` — and a re-login hint), including a provider section when no active credential remains. User-driven tombstones (`replaced by newer credential`, `deleted by user`) and API-key rows stay hidden. Requires a broker with `GET /v1/credentials/disabled`; older brokers degrade to no tombstone rows.
- `omp usage` warns about Anthropic's ~30-day OAuth grant lifetime: accounts whose interactive login (`authorizedAt`) is within a week of the deadline get a yellow `⚠ re-login within <time>` line, and past-deadline accounts a red one. Grants die server-side exactly ~30 days after login regardless of refresh rotation, so this is the only warning before the broker auto-disables the row.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
loadExtensions,
} from "./loader";
export * from "./runner";
export * from "./todo-projection";
// Type guards
export * from "./types";
export * from "./wrapper";
9 changes: 9 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
import { getAllPluginExtensionPaths } from "../plugins/loader";

import { resolvePath, withHostGuard } from "../utils";
import type { TodoProjectionPhase } from "./todo-projection";
import type {
AssistantThinkingRenderer,
Extension,
Expand Down Expand Up @@ -131,6 +132,10 @@ export class ExtensionRuntime implements IExtensionRuntime {
setSessionName(): Promise<void> {
throw new ExtensionRuntimeNotInitializedError();
}

setTodoProjection(): void {
throw new ExtensionRuntimeNotInitializedError();
}
}

/**
Expand Down Expand Up @@ -288,6 +293,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
return this.runtime.setSessionName(name);
}

setTodoProjection(namespace: string, phases: readonly TodoProjectionPhase[] | undefined): void {
this.runtime.setTodoProjection(namespace, phases);
}

registerProvider(name: string, config: ProviderConfig): void {
this.runtime.pendingProviderRegistrations.push({ name, config, sourceId: this.extension.path });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export class ExtensionRunner {
this.runtime.setServiceTier = actions.setServiceTier ?? throwUnsupportedServiceTierAction;
this.runtime.getSessionName = actions.getSessionName;
this.runtime.setSessionName = actions.setSessionName;
this.runtime.setTodoProjection = actions.setTodoProjection;

// Context actions (required)
this.#getModel = contextActions.getModel;
Expand Down
144 changes: 144 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/todo-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export type TodoProjectionStatus = "pending" | "in_progress" | "completed" | "failed" | "cancelled" | "abandoned";

/** A host-rendered todo owned by an extension namespace. */
export interface TodoProjectionItem {
/** Stable within the namespace for the lifetime of the projected work item. */
readonly id: string;
readonly content: string;
readonly status: TodoProjectionStatus;
}

/** A display group for extension-owned projected todos. */
export interface TodoProjectionPhase {
/** Stable within the namespace for the lifetime of the projected phase. */
readonly id: string;
readonly name: string;
readonly tasks: readonly TodoProjectionItem[];
}

/** Read-only snapshot exposed by the host for rendering and inspection. */
export interface NamespacedTodoProjection {
readonly namespace: string;
readonly phases: readonly TodoProjectionPhase[];
}

const TODO_PROJECTION_STATUSES: Record<TodoProjectionStatus, true> = {
pending: true,
in_progress: true,
completed: true,
failed: true,
cancelled: true,
abandoned: true,
};

function requireStableId(value: string, kind: "phase" | "task"): string {
const id = value.trim();
if (!id) throw new Error(`Todo projection ${kind} id must be non-empty`);
return id;
}

export function normalizeTodoProjectionNamespace(namespace: string): string {
const normalized = namespace.trim();
if (!normalized) throw new Error("Todo projection namespace must be non-empty");
return normalized;
}

function assertDenseTodoProjectionArray(values: readonly unknown[], label: "phase" | "task"): void {
for (let index = 0; index < values.length; index++) {
if (!Object.hasOwn(values, index)) {
throw new Error(`Todo projection ${label} array must not contain holes (missing index ${index})`);
}
}
}

/**
* Validate and clone extension-owned data at the public API boundary. The
* returned value contains only fields the host renders; extra caller metadata
* is deliberately discarded.
*/
export function cloneTodoProjection(phases: readonly TodoProjectionPhase[]): TodoProjectionPhase[] {
const phaseIds = new Set<string>();
const taskIds = new Set<string>();
assertDenseTodoProjectionArray(phases, "phase");
for (const phase of phases) assertDenseTodoProjectionArray(phase.tasks, "task");
return phases.map(phase => {
const id = requireStableId(phase.id, "phase");
if (phaseIds.has(id)) throw new Error(`Duplicate todo projection phase id: ${id}`);
phaseIds.add(id);
const name = phase.name.trim();
if (!name) throw new Error(`Todo projection phase ${id} must have a non-empty name`);
const tasks = phase.tasks.map(task => {
Comment thread
panosAthDBX marked this conversation as resolved.
const taskId = requireStableId(task.id, "task");
if (taskIds.has(taskId)) throw new Error(`Duplicate todo projection task id: ${taskId}`);
taskIds.add(taskId);
const content = task.content.trim();
if (!content) throw new Error(`Todo projection task ${taskId} must have non-empty content`);
if (TODO_PROJECTION_STATUSES[task.status] !== true) {
throw new Error(`Invalid todo projection status for ${taskId}: ${String(task.status)}`);
}
return { id: taskId, content, status: task.status };
});
return { id, name, tasks };
});
}

export function cloneNamespacedTodoProjections(
projections: ReadonlyMap<string, readonly TodoProjectionPhase[]>,
): NamespacedTodoProjection[] {
return [...projections.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([namespace, phases]) => ({ namespace, phases: cloneTodoProjection(phases) }));
}

function todoProjectionPhasesEqual(
left: readonly TodoProjectionPhase[],
right: readonly TodoProjectionPhase[],
): boolean {
if (left.length !== right.length) return false;
for (let phaseIndex = 0; phaseIndex < left.length; phaseIndex++) {
const leftPhase = left[phaseIndex]!;
const rightPhase = right[phaseIndex]!;
if (leftPhase.id !== rightPhase.id || leftPhase.name !== rightPhase.name) return false;
if (leftPhase.tasks.length !== rightPhase.tasks.length) return false;
for (let taskIndex = 0; taskIndex < leftPhase.tasks.length; taskIndex++) {
const leftTask = leftPhase.tasks[taskIndex]!;
const rightTask = rightPhase.tasks[taskIndex]!;
if (
leftTask.id !== rightTask.id ||
leftTask.content !== rightTask.content ||
leftTask.status !== rightTask.status
) {
return false;
}
}
}
return true;
}

/**
* Session-owned store for display-only projections. It has no dependency on
* canonical todo state or transcript persistence.
*/
export class TodoProjectionStore {
#projections = new Map<string, TodoProjectionPhase[]>();

set(namespace: string, phases: readonly TodoProjectionPhase[] | undefined): boolean {
const key = normalizeTodoProjectionNamespace(namespace);
if (phases === undefined) return this.#projections.delete(key);
const next = cloneTodoProjection(phases);
const current = this.#projections.get(key);
if (current && todoProjectionPhasesEqual(current, next)) return false;
this.#projections.set(key, next);
return true;
}

snapshot(): NamespacedTodoProjection[] {
return cloneNamespacedTodoProjections(this.#projections);
}

clear(): boolean {
if (this.#projections.size === 0) return false;
this.#projections.clear();
return true;
}
}
11 changes: 11 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import type {
TurnStartEvent,
} from "../shared-events";
import type { SlashCommandInfo } from "../slash-commands";
import type { TodoProjectionPhase } from "./todo-projection";

export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
export type { ExecOptions, ExecResult } from "../../exec/exec";
Expand Down Expand Up @@ -1297,6 +1298,15 @@ export interface ExtensionAPI {
tier: ExtensionServiceTier<Family> | undefined,
): void;

/**
* Replace the derived todo/status projection owned by `namespace` in the
* current session. Pass `undefined` to remove that namespace.
*
* Projection state is display-only: it never mutates native todos, enters
* the transcript, or participates in todo reminders and normalization.
*/
setTodoProjection(namespace: string, phases: readonly TodoProjectionPhase[] | undefined): void;

/** Get the current session name. */
getSessionName(): string | undefined;

Expand Down Expand Up @@ -1504,6 +1514,7 @@ export interface ExtensionActions {
setServiceTier?: SetServiceTierHandler;
getSessionName: () => string | undefined;
setSessionName: (name: string) => Promise<void>;
setTodoProjection: (namespace: string, phases: readonly TodoProjectionPhase[] | undefined) => void;
}

/** Actions for ExtensionContext (ctx.* in event handlers). */
Expand Down
Loading
Loading