Skip to content

feat(openchamber): OpenChamber-parity feature set + capability gating#1604

Open
jms830 wants to merge 1 commit into
getpaseo:mainfrom
jms830:openchamber-parity
Open

feat(openchamber): OpenChamber-parity feature set + capability gating#1604
jms830 wants to merge 1 commit into
getpaseo:mainfrom
jms830:openchamber-parity

Conversation

@jms830

@jms830 jms830 commented Jun 19, 2026

Copy link
Copy Markdown

OpenChamber-parity feature set + capability gating

Ports a set of features inspired by OpenChamber (MIT) into Paseo, each gated behind server_info.features.* capability flags so old clients/daemons degrade gracefully per the protocol contract. All ported files carry a // Ported from openchamber/openchamber (MIT) header.

Features

Server-backed (WebSocket-RPC, mirrors the existing schedule pattern):

  • Quota / usage — provider usage tracking (Claude, Codex, GitHub Copilot, OpenRouter) read from local auth; quota/list + quota/fetch; usage progress bar in host settings.
  • Skills catalog — browse/scan/install skills from a git repo into ~/.pi/agent/skills; skills/list + skills/scan + skills/install.
  • Gitmoji commits — optional gitmoji prefix on generated commit messages (off by default; General-settings toggle).
  • Git identities — per-repo git author/committer identity via git config; git-identity/get + set.
  • Cloudflare quick tunnel — expose the daemon via cloudflared tunnel --url; tunnel/status + start + stop.

Client-only (Zustand + AsyncStorage persistence):

  • Workspace folders — group workspaces into collapsible folders in the sidebar.
  • Multi-run launcher — run one prompt across multiple models in parallel (one agent per model).
  • Project notes/todos — a Notes tab in the explorer sidebar.
  • i18n runtime — typed useI18n() / translate() with an English catalog (foundation; English-only for now).

Notes

  • Protocol stays backward-compatible: new RPCs use dotted domain.op.request/.response namespaces; new schema fields are optional; capability flags gate each feature.
  • Tests: unit tests for quota, skills, gitmoji, tunnel (server) and workspace-folders, git-identities, multi-run, project-notes, i18n (app).
  • Verified: typecheck (all workspaces), oxlint, oxfmt all green on 0.1.97-beta.2. Smoke-tested live in the web app (folders, multi-run, notes, git identity, gitmoji toggle render and function).

Opening for maintainer feedback — happy to split into smaller PRs or adjust the capability-gating approach.

Layers the OpenChamber feature set onto Paseo v0.1.96 (upstream af0437e):

- RPC-backed: provider usage/quota, skills catalog, Cloudflare tunnel, per-repo
  git identity. Schemas/types in @getpaseo/protocol, services in @getpaseo/server,
  client methods in @getpaseo/client, UI sections in app.
- Each RPC-backed section is gated behind a server_info.features.* flag
  (quota/skillsCatalog/gitIdentity/tunnel) so it degrades gracefully on older daemons.
- Client-side: workspace folders, project notes, multi-run launcher, gitmoji
  commit toggle, typed-i18n runtime.
- Dev tooling: scripts/dev-web.sh + npm run dev:web (auto-scan ports, stable PASEO_HOME).

typecheck (protocol/server/app) green; oxlint 0/0; web bundle compiles.
@jms830 jms830 force-pushed the openchamber-parity branch from 5026f7c to 58069b9 Compare June 19, 2026 02:24
@jms830 jms830 marked this pull request as ready for review June 19, 2026 19:15
@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR ports five OpenChamber feature areas (quota tracking, skills catalog, gitmoji commits, git identities, Cloudflare tunnel) into the server layer and six client-only features (workspace folders, multi-run launcher, project notes, i18n) into the app layer. Each server feature follows the existing ScheduleService pattern: a new service class, a protocol schema pair in /protocol, dispatch methods in Session, and capability flags in ServerInfoStatus so older clients degrade gracefully.

  • Server features land as isolated service modules wired into Session via constructor injection; protocol schemas are additive-only with all new fields optional.
  • Client features are pure Zustand + AsyncStorage stores with no server-side dependencies, cleanly isolated in their own files.
  • One dispatch gap: handleSkillsScanRequest and handleSkillsInstallRequest lack the try/catch present in every other new handler; exceptions from filesystem failures (e.g., mkdtemp out of space) propagate out of the dispatch loop instead of emitting an error response, leaving the client to time out.
  • Test quality: tunnel.test.ts uses vi.mock (banned by project rules); quota.test.ts and skills-catalog.test.ts pin internal formatter/parser helpers rather than crossing the service interface.

Confidence Score: 3/5

Safe to merge for UI-only flows; the skills scan/install path has a real gap where filesystem failures silently leave the client hanging instead of receiving an error response.

The skills scan and install session handlers omit the error boundary that every other new handler in this file includes. A transient OS condition (temp directory creation failing, disk full) during a skills scan or install call will propagate an uncaught exception out of the dispatch loop rather than sending the client an error payload, causing a 120-second correlated-request timeout on the caller side. The fix is small and mechanical, but the behavior difference is observable and affects correctness of the skills feature in adverse conditions.

packages/server/src/server/session.ts (handleSkillsScanRequest, handleSkillsInstallRequest — missing error boundaries); packages/server/src/server/tunnel/tunnel.test.ts (vi.mock usage); packages/server/src/server/quota/quota.test.ts and skills-catalog/skills-catalog.test.ts (test internals rather than service interface)

Important Files Changed

Filename Overview
packages/server/src/server/session.ts Integrates four new services into Session's dispatch chain; skills scan/install handlers are missing try/catch unlike all other new handlers — unhandled throws produce client timeouts instead of error responses.
packages/server/src/server/tunnel/tunnel.test.ts Uses vi.mock to replace the spawn module, which is a banned test pattern for this project; the real spawn path is never exercised.
packages/server/src/server/quota/quota.test.ts Tests internal formatters and transformers rather than the QuotaService interface; implementation details are pinned while the behavior callers depend on goes untested.
packages/server/src/server/skills-catalog/repo.ts Clones git repositories into temp directories and copies skill files with symlink rejection and path traversal guards; cleanup is in finally blocks and skill names are validated before installation.
packages/server/src/server/quota/auth.ts Reads auth tokens from local JSON files; normalizeAuthEntry has an unreachable final branch that can be simplified; tokens are not exposed in error messages.
packages/server/src/server/websocket-server.ts Instantiates the four new services as singleton fields and passes them into every new Session; TunnelService singleton is correct since cloudflared should only run once per server instance.
packages/protocol/src/messages.ts Adds ten new inbound/outbound schema entries and four optional capability flags; all fields are optional, maintaining backward compatibility.
packages/server/src/server/tunnel/cloudflare-tunnel.ts Manages cloudflared process lifecycle with state transitions, timeout handling, and SIGTERM cleanup; logic is correct and port is injected externally so injection risk is absent.
packages/app/src/stores/workspace-folders-store.ts Zustand + AsyncStorage store for collapsible workspace folders; cascade delete, stale workspace cleanup, and persisted-state sanitization are all handled.
packages/server/src/server/git-identity/service.ts Reads and writes local git config for user.name/email via execCommand with args array (no shell injection risk).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App as App (client)
    participant DC as DaemonClient
    participant WS as WebSocketServer
    participant S as Session
    participant Svc as Service (Quota/Skills/GitId/Tunnel)

    App->>DC: quotaFetch(providerId)
    DC->>WS: "quota/fetch {requestId, providerId}"
    WS->>S: dispatchMessage(msg)
    S->>S: dispatchQuotaMessage(msg)
    S->>Svc: quotaService.fetch(providerId)
    Svc-->>S: ProviderResult
    S-->>WS: "emit quota/fetch/response {result}"
    WS-->>DC: correlated response
    DC-->>App: QuotaFetchPayload

    Note over App,Svc: skills/scan and skills/install follow same path but handlers lack try/catch
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App as App (client)
    participant DC as DaemonClient
    participant WS as WebSocketServer
    participant S as Session
    participant Svc as Service (Quota/Skills/GitId/Tunnel)

    App->>DC: quotaFetch(providerId)
    DC->>WS: "quota/fetch {requestId, providerId}"
    WS->>S: dispatchMessage(msg)
    S->>S: dispatchQuotaMessage(msg)
    S->>Svc: quotaService.fetch(providerId)
    Svc-->>S: ProviderResult
    S-->>WS: "emit quota/fetch/response {result}"
    WS-->>DC: correlated response
    DC-->>App: QuotaFetchPayload

    Note over App,Svc: skills/scan and skills/install follow same path but handlers lack try/catch
Loading

Reviews (1): Last reviewed commit: "feat(openchamber): OpenChamber-parity fe..." | Re-trigger Greptile

Comment on lines +10249 to +10272
type: "skills/install/response",
payload: {
requestId: request.requestId,
installed: result.ok ? result.installed : [],
skipped: result.ok ? result.skipped : [],
error: result.ok ? null : result.error,
},
});
}

private async handleGitIdentityGetRequest(
request: Extract<SessionInboundMessage, { type: "git-identity/get" }>,
): Promise<void> {
try {
const identity = await this.gitIdentityService.get(request.cwd);
this.emit({
type: "git-identity/get/response",
payload: { requestId: request.requestId, identity, error: null },
});
} catch (error) {
this.emit({
type: "git-identity/get/response",
payload: {
requestId: request.requestId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing error boundary in skills scan and install handlers

handleSkillsScanRequest and handleSkillsInstallRequest call the service but have no try/catch, unlike every other handler added in this PR (quota, git-identity, tunnel). If scanRepository throws before returning a result — e.g., mkdtemp fails with ENOSPC or a filesystem error escapes the finally block — the exception propagates to the dispatch loop. The client receives no response and the request times out on the 120-second correlated-request timeout.

All other handlers in this file emit an error payload in their catch branch. Both skills handlers need the same treatment to match that contract.

Comment on lines +1 to +12
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const spawnProcessMock = vi.fn();
const execCommandMock = vi.fn();

vi.mock("../../utils/spawn.js", () => ({
spawnProcess: (...args: unknown[]) => spawnProcessMock(...args),
execCommand: (...args: unknown[]) => execCommandMock(...args),
}));

import { CloudflareTunnel } from "./cloudflare-tunnel.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 vi.mock is a banned test pattern for this codebase

The tunnel test hoists vi.mock("../../utils/spawn.js", ...) to replace spawnProcess and execCommand. The project's review rules explicitly ban vi.mock and vi.hoisted. The test also imports vi from vitest for vi.fn() and vi.waitFor, which means the system under test is never exercised against the real execCommand path.

The idiomatic alternative is a typed fake: pass a Spawner port into CloudflareTunnel (or TunnelService) and wire the real spawnProcess in production and an in-memory fake in tests. That way the spawn seam is named and the test verifies real behavior.

Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +1 to +73
import { describe, expect, it } from "vitest";
import {
buildResult,
calculateResetAfterSeconds,
formatMoney,
toUsageWindow,
} from "./formatters.js";
import { toNumber, toTimestamp } from "./transformers.js";
import { fetchQuotaForProvider } from "./providers/index.js";

describe("quota transformers", () => {
it("coerces numbers from strings and rejects junk", () => {
expect(toNumber(42)).toBe(42);
expect(toNumber("3.5")).toBe(3.5);
expect(toNumber("nope")).toBeNull();
expect(toNumber(null)).toBeNull();
});

it("normalizes timestamps (seconds -> ms, ISO -> ms)", () => {
expect(toTimestamp(1_000)).toBe(1_000_000);
expect(toTimestamp(2_000_000_000_000)).toBe(2_000_000_000_000);
expect(toTimestamp("2025-01-01T00:00:00Z")).toBe(Date.parse("2025-01-01T00:00:00Z"));
expect(toTimestamp(null)).toBeNull();
});
});

describe("quota formatters", () => {
it("formats money to two decimals", () => {
expect(formatMoney(3.5)).toBe("3.50");
expect(formatMoney("x")).toBeNull();
});

it("derives remaining percent from used percent", () => {
const window = toUsageWindow({ usedPercent: 30, windowSeconds: 3600, resetAt: null });
expect(window.remainingPercent).toBe(70);
expect(window.windowSeconds).toBe(3600);
});

it("leaves remaining percent null when usedPercent is null", () => {
const window = toUsageWindow({ usedPercent: null, windowSeconds: null, resetAt: null });
expect(window.remainingPercent).toBeNull();
});

it("clamps negative reset deltas to zero", () => {
expect(calculateResetAfterSeconds(Date.now() - 10_000)).toBe(0);
expect(calculateResetAfterSeconds(null)).toBeNull();
});

it("builds a result with fetchedAt and optional error", () => {
const ok = buildResult({ providerId: "x", providerName: "X", ok: true, configured: true });
expect(ok.usage).toBeNull();
expect(ok.fetchedAt).toBeGreaterThan(0);
expect("error" in ok).toBe(false);

const failed = buildResult({
providerId: "x",
providerName: "X",
ok: false,
configured: false,
error: "boom",
});
expect(failed.error).toBe("boom");
});
});

describe("quota registry", () => {
it("returns an unsupported result for unknown providers", async () => {
const result = await fetchQuotaForProvider("does-not-exist");
expect(result.ok).toBe(false);
expect(result.configured).toBe(false);
expect(result.error).toBe("Unsupported provider");
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests reach into internal implementation helpers rather than the module interface

The test imports buildResult, calculateResetAfterSeconds, formatMoney, toUsageWindow from formatters.ts and toNumber, toTimestamp from transformers.ts. These are private implementation files; no external caller imports them. The actual module interface visible to callers is QuotaService.listConfigured() and QuotaService.fetch(), and neither is tested here. The same pattern applies to skills-catalog.test.ts, which tests parseSkillRepoSource and parseSkillFrontmatter instead of SkillsCatalogService.scan() / .install().

Per the project review standard, tests should cross the same interface as callers. Tests for internal formatters lock in implementation details without verifying the behavior the session handler (and the client) actually depends on.

Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +60 to +66
if (typeof entry === "string") {
return { token: entry };
}
if (typeof entry === "object") {
return entry;
}
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unreachable return null — dead code after exhaustive branches

The parameter type is AuthEntry | string | null. After !entry (catches null) and typeof entry === "string", the only remaining case is AuthEntry (an object). The if (typeof entry === "object") guard is always true at that point, so return null on the last line can never execute. Remove the redundant guard to keep the logic honest and avoid confusion about when null is returned.

Suggested change
if (typeof entry === "string") {
return { token: entry };
}
if (typeof entry === "object") {
return entry;
}
return null;
if (typeof entry === "string") {
return { token: entry };
}
return entry;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant