Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 31 additions & 4 deletions docs/gentle-agents-activity.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,37 @@ Presence reads the current canonical Pi name on its existing five-second heartbe
including idle `/name` or session-picker renames; declaration refreshes it immediately.
An unnamed session retains its workspace-basename display fallback. Session replacement
or shutdown disposes the previous publisher; a stale name source stops publication.
Headers and optional metadata sidecar contracts remain unchanged.

Repository identity and extended work scope remain for a later stacked slice.
Recorded launch directories do not prove repository identity or freedom from shared
Headers remain unchanged; the optional sidecar now also carries `scope`.

### Recorded repository scope

Scope reuses `resolveSessionWorktree`: canonical Git root plus a SHA-256 hash of
canonical common-directory identity, with ambient `GIT_*` routing excluded. Sibling
worktrees share a clone hash, not a root; separate clones differ. No remote URL or
credential is read, and neither names nor scope grants authority. Non-Git, missing,
or unsafe paths are unknown, never guessed or shortened (scope paths: 256 bytes).
Literal scope paths containing NBSP, U+2000–200A, U+202F, U+205F or U+3000 are
unknown: the shared spelling resolver maps them to ASCII space and could otherwise
select a different existing repository. Input, resolved-root output and sidecar
readback all reject them. Ordinary spaces and Unicode letters remain supported.

`scope.host`, child `repository` facts keyed by task ID, and up to eight `registered`
facts carry `source: recorded-workspace/git` and `resolvedAt` (resolution attempt
time, not heartbeat age). Registered roots come from the existing session registry's
durable entries, not another registry. Missing/pruned registrations resolve unknown.
One bounded derived snapshot caches successful and unknown resolutions by actual Pi
cwd, admitted launch cwd/membership, and registered roots. Lifecycle changes and
session replacement invalidate it; stable token updates/heartbeats do not probe Git.

`omittedTasks`, `omittedRegistered`, and `complete` describe listing bounds. If the
sidecar byte budget cannot fit scope lists, both lists are withheld with exact
omission counts while retaining host context. Full registered-scope continuation
and interactive live-profile verification remain follow-ups; #1701 stays open.
New readers accept legacy sidecars without scope. Old strict optional-sidecar
readers may show unknown discovery context; activity visibility is unchanged.
Malformed scope alone falls back to unknown repository facts without hiding IDs.

Recorded launch directories do not prove current child cwd or freedom from shared
artifacts. A shell `cd` does not change Pi's session cwd. Omission counts mean the
child list is incomplete, not an exhaustive writer inventory. This metadata cannot
answer arbitrary reasoning questions (#1702).
Expand Down
31 changes: 27 additions & 4 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts";
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
Expand Down Expand Up @@ -35,6 +35,7 @@ import { AgentsView } from "../lib/agents-view.ts";
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
import { PresencePublisher, sanitizeDisplayLabel } from "../lib/orchestrator-presence.ts";
import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts";
import { OrchestratorScopeCache, type RepositoryFact } from "../lib/orchestrator-scope.ts";
import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
Expand Down Expand Up @@ -387,6 +388,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
let stopVisualUpdates: (() => void) | undefined;
let sessions: ExtensionContext["sessionManager"] | undefined;
let presence: PresencePublisher | undefined;
const scopeCache = new OrchestratorScopeCache(deps.resolveWorktree);
let rpcActivityPublisher: RpcActivityPublisher | undefined;
// Messages already surfaced to the user this session through the RPC
// activity publisher's `onError`, so a recurring push failure (the
Expand All @@ -395,6 +397,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
let notifiedRpcActivityErrors: Set<string> | undefined;
const overlays = new Set<AgentsView>();
const startPresence = (manager: ExtensionContext["sessionManager"]) => {
scopeCache.clear();
const sessionId = manager.getSessionId() ?? "";
const labelSource = () => {
if (sessions !== manager || (manager.getSessionId() ?? "") !== sessionId) throw new Error("stale-session");
Expand All @@ -413,10 +416,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
presence?.update(tasks.map((task) => ({ task, thread: store.thread(task.id) })));
const transport = activeSessionTransport;
if (transport?.sessionManager === sessions && transport.sessionId === activeSessionId() && transport.listener.record) {
presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks });
// Read the existing durable registry, without roots()'s repeated Git validation.
// These are recorded contexts, never authority for admission or messaging.
const registered = sessions.getEntries().flatMap(entry => {
if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY) return [];
const data = entry.data as { sessionId?: string; root?: string; evidence?: string } | undefined;
return data?.sessionId === activeSessionId() && typeof data.root === "string" && typeof data.evidence === "string" ? [data.root] : [];
});
presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks,
scope: scopeCache.project(sessions.getCwd(), tasks, registered) });
}
} catch { presence?.dispose(); presence = undefined; }
};
const unsubscribeScope = pi.events.on(SESSION_WORKTREE_CHANGED, (event) => {
if ((event as { sessionId?: string } | undefined)?.sessionId !== sessions?.getSessionId()) return;
scopeCache.clear();
publishActivity();
});
let worktrees: SessionWorktreeRegistry | undefined;
let worktreeManager: ExtensionContext["sessionManager"] | undefined;
let worktreeAuthority: (() => boolean) | undefined;
Expand Down Expand Up @@ -461,6 +477,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
pi.on("session_shutdown", () => {
clearTaskMetrics();
unsubscribeMetrics();
unsubscribeScope();
scopeCache.clear();
});
let stopAllConfirmation: Promise<void> | undefined;

Expand Down Expand Up @@ -1552,10 +1570,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
const activations = await transport.listener.registry.listActivations(transport.sessionId);
if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" });
const peers = discoverOrchestrators(agentHome, activations);
const repository = (fact?: RepositoryFact) => fact?.root
? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})`
: "repository: unknown";
const rows = peers.map(peer => {
const context = peer.freshness === "recent" ? ` · ${peer.label || "unnamed"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown";
const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"}`).join("") ?? "";
return `- ${peer.sessionId}${context} · metadata: ${peer.freshness}${tasks}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? "";
const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? "";
const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : "";
return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
});
return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } });
} catch {
Expand Down
3 changes: 2 additions & 1 deletion lib/orchestrator-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface OrchestratorCandidate {
workspace?: string;
tasks?: DiscoveryMetadata["tasks"];
omitted?: number;
scope?: DiscoveryMetadata["scope"];
}

/** One bounded metadata page, no thread reads or transport probes. The registry
Expand All @@ -29,6 +30,6 @@ export function discoverOrchestrators(profile: string, peers: readonly PresenceR
if (metadata?.activation !== activationHash(activations[0])) return unknown;
if (!header.recent) return { ...unknown, freshness: "stale" };
return { ...unknown, freshness: "recent", label: header.label, workspace: metadata.workspace,
tasks: metadata.tasks, omitted: metadata.omitted };
tasks: metadata.tasks, omitted: metadata.omitted, ...(metadata.scope ? { scope: metadata.scope } : {}) };
});
}
21 changes: 17 additions & 4 deletions lib/orchestrator-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs";
import { dirname, join, resolve } from "node:path";
import { stripVTControlCharacters } from "node:util";
import { validRecordedScope, type RecordedScope } from "./orchestrator-scope.ts";

// Same-profile OS-user trust boundary, not an authorization channel. POSIX modes
// restrict newly created storage; Windows deployments must supply their own ACLs.
Expand All @@ -28,6 +29,7 @@ export interface Activity { tasks: { summary: ActivityInput["task"]; thread: {
} }[] }
export interface Target { sessionHash: string; incarnation: string }
export interface DiscoveryMetadata {
scope?: RecordedScope;
activation: string;
workspace: string;
tasks: { id: string; label: string; status: string; workspace: string }[];
Expand Down Expand Up @@ -90,7 +92,7 @@ function validTarget(value: Target) {
&& typeof value.incarnation === "string" && UUID.test(value.incarnation);
}
function validDiscovery(d: unknown): d is DiscoveryMetadata {
return object(d) && keys(d, ["activation", "workspace", "tasks", "omitted"])
return object(d) && keys(d, Object.hasOwn(d, "scope") ? ["activation", "workspace", "tasks", "omitted", "scope"] : ["activation", "workspace", "tasks", "omitted"])
&& typeof d.activation === "string" && HASH.test(d.activation)
&& typeof d.workspace === "string" && d.workspace === label(d.workspace) && integer(d.omitted)
&& Array.isArray(d.tasks) && d.tasks.length <= 8 && d.tasks.every((t: unknown) => object(t)
Expand Down Expand Up @@ -263,7 +265,8 @@ export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | u
if (!object(value) || !keys(value, ["schema", "sessionHash", "incarnation", "generation", "metadata"])
|| value.schema !== 1 || value.sessionHash !== h.sessionHash || value.incarnation !== h.incarnation
|| value.generation !== h.generation || !validDiscovery(value.metadata)) return undefined;
return value.metadata;
const { scope, ...legacy } = value.metadata;
return validRecordedScope(scope) ? { ...legacy, scope } : legacy;
} catch { return undefined; }
}

Expand Down Expand Up @@ -308,15 +311,25 @@ export class PresencePublisher {
/** Metadata-only projection; never reads task prompts, results, or threads.
* Binding to the listener activation prevents reused session IDs from joining. */
updateDiscovery(peer: { sessionId: string; endpoint: string; createdAt: number }, input: {
workspace: string; tasks: readonly { id: string; label: string; status: string; cwd: string }[];
workspace: string; tasks: readonly { id: string; label: string; status: string; cwd: string }[]; scope?: RecordedScope;
}) {
if (this.disposed) throw new Error("disposed");
if (sessionHash(peer.sessionId) !== this.target.sessionHash) throw new Error("malformed-discovery");
// A shortened path could denote a different workspace; do not advertise it.
const workspace = (path: string) => Array.from(path).length <= 120 && label(path) === path ? path : "";
const discovery = { activation: activationHash(peer), workspace: workspace(input.workspace),
const discovery: DiscoveryMetadata = { activation: activationHash(peer), workspace: workspace(input.workspace),
tasks: input.tasks.slice(0, 8).map(t => ({ id: label(t.id), label: label(t.label), status: t.status, workspace: workspace(t.cwd) })),
omitted: Math.max(0, input.tasks.length - 8) };
if (input.scope && validRecordedScope(input.scope)) {
discovery.scope = structuredClone(input.scope);
// Leave explicit gaps instead of publishing a sidecar readers cannot fit.
if (Buffer.byteLength(JSON.stringify(discovery)) > HEADER_LIMIT - 1024) {
const scope = discovery.scope;
discovery.scope = { ...scope, tasks: [], registered: [],
omittedTasks: scope.tasks.length + scope.omittedTasks,
omittedRegistered: scope.registered.length + scope.omittedRegistered, complete: false };
}
}
if (!validDiscovery(discovery)) throw new Error("malformed-discovery");
if (JSON.stringify(discovery) === JSON.stringify(this.discovery)) return;
this.discovery = discovery;
Expand Down
80 changes: 80 additions & 0 deletions lib/orchestrator-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";

export interface RepositoryFact {
root: string | null;
cloneHash: string | null;
resolvedAt: number;
source: "recorded-workspace/git";
}
export interface RecordedScope {
host: RepositoryFact;
tasks: { id: string; repository: RepositoryFact }[];
registered: RepositoryFact[];
omittedTasks: number;
omittedRegistered: number;
complete: boolean;
}
// Recorded cwd/root is literal, not a user spelling. The shared resolver maps
// these Unicode separators to ASCII space; reject them before resolving and on
// output/readback rather than aliasing a distinct directory or truncating a root.
const safeRoot = (root: string) => isAbsolute(root) && Buffer.byteLength(root) <= 256
&& !/[\p{Cc}\p{Cf}\u00a0\u2000-\u200a\u202f\u205f\u3000]/u.test(root);
const object = (v: any) => v && typeof v === "object" && !Array.isArray(v);
const keys = (v: object, names: string[]) => Object.keys(v).length === names.length && names.every(k => Object.hasOwn(v, k));
const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0;
export function validRecordedScope(value: any): value is RecordedScope {
const fact = (v: any) => object(v) && keys(v, ["cloneHash", "resolvedAt", "root", "source"])
&& v.source === "recorded-workspace/git" && count(v.resolvedAt)
&& ((v.root === null && v.cloneHash === null) || (typeof v.root === "string" && safeRoot(v.root)
&& typeof v.cloneHash === "string" && /^[a-f0-9]{64}$/.test(v.cloneHash)));
return object(value) && keys(value, ["complete", "host", "omittedRegistered", "omittedTasks", "registered", "tasks"])
&& fact(value.host) && Array.isArray(value.tasks) && value.tasks.length <= 8
&& value.tasks.every((t: any) => object(t) && keys(t, ["id", "repository"])
&& typeof t.id === "string" && Buffer.byteLength(t.id) <= 512 && fact(t.repository))
&& Array.isArray(value.registered) && value.registered.length <= 8 && value.registered.every(fact)
&& count(value.omittedTasks) && count(value.omittedRegistered)
&& value.complete === (value.omittedTasks === 0 && value.omittedRegistered === 0);
}

/** One bounded derivative snapshot, not a registry or authority. The caller supplies
* only admitted, unfinished, non-restored owned children and durable registry roots.
* Membership/cwd changes invalidate even unknown facts; session replacement clears.
* No resolution runs on a stable heartbeat/token update. */
export class OrchestratorScopeCache {
private key = "";
private snapshot?: RecordedScope;
private resolver: WorktreeResolver;
private now: () => number;
constructor(resolver = resolveSessionWorktree, now = Date.now) {
this.resolver = resolver;
this.now = now;
}
clear() {
this.key = "";
this.snapshot = undefined;
}
project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
const registered = [...new Set(roots)];
const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
if (key === this.key && this.snapshot) return this.snapshot;
const resolvedAt = this.now();
const facts = new Map<string, RepositoryFact>();
const fact = (path: string) => {
if (facts.has(path)) return facts.get(path)!;
const unknown: RepositoryFact = { root: null, cloneHash: null, resolvedAt, source: "recorded-workspace/git" };
let identity;
try { identity = safeRoot(path) ? this.resolver(path, path) : undefined; } catch { /* Unavailable is unknown. */ }
const result = identity && safeRoot(identity.root) ? { ...unknown, root: identity.root,
cloneHash: createHash("sha256").update(identity.commonDir).digest("hex") } : unknown;
facts.set(path, result);
return result;
};
this.snapshot = { host: fact(cwd), tasks: tasks.slice(0, 8).map(t => ({ id: t.id, repository: fact(t.cwd) })),
registered: registered.slice(0, 8).map(fact), omittedTasks: Math.max(0, tasks.length - 8),
omittedRegistered: Math.max(0, registered.length - 8), complete: tasks.length <= 8 && registered.length <= 8 };
this.key = key;
return this.snapshot;
}
}
Loading
Loading