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
120 changes: 77 additions & 43 deletions apps/server/src/agents/manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,15 @@ import {
writeLatestEventIfCurrent,
} from "./events.js";
import { runLifecycleHook } from "./lifecycle-hooks.js";
import { clearBlankPinFields, mergePin } from "./pin-merge.js";
import {
MAX_PINS,
type PinSpec,
applyPinSpec,
applyPinSpecs,
removePinGroup,
removePinsByIds,
replacePinGroup,
} from "./pin-write.js";
import {
validatePinCaption,
validatePinShortcutFields,
Expand DownExpand Up@@ -90,14 +98,6 @@ export type {
const CODEX_FULL_ACCESS_ARG = "--dangerously-bypass-approvals-and-sandbox";
const CLAUDE_FULL_ACCESS_ARG = "--dangerously-skip-permissions";

/**
* Maximum number of pins per agent. Enforced by `upsertPin` when adding
* one at a time and by `normalizeInitialPins` when seeding via
* `createAgent({ initialPins })`. Pins also flow into the startup
* prompt via `buildStartupPrompt`, so the cap also bounds prompt size.
*/
const MAX_PINS = 50;

/**
* Validate + de-duplicate the `initialPins` array supplied to
* `createAgent`. De-dup is case-insensitive on label with last-write-wins
Expand DownExpand Up@@ -1135,41 +1135,27 @@ export class AgentManager {
}

/**
* Update in place when the label already exists, append otherwise. Position
* Update in place when the pin already exists, append otherwise. Position
* is deliberately stable: re-pinning to refresh a value must not shuffle the
* sidebar out from under the user, and grouped pins would tear apart if an
* update relocated a member. An agent that wants a pin moved deletes it and
* pins it again.
* update relocated a member.
*
* The pin is addressed by `id` when the caller supplies one and by label
* otherwise — see `applyPinSpec`, which both this and the batch path share
* so the two cannot drift apart.
*/
async upsertPin(
id: string,
pin: AgentPin
pin: PinSpec
): Promise<{ agent: AgentRecord; pin: AgentPin; created: boolean }> {
let stored: AgentPin = pin;
// Assigned by the mutation below, which always runs before we read it.
let stored!: AgentPin;
let created = true;
await this.mutatePins(id, (currentPins) => {
const index = currentPins.findIndex(
(p) => p.label.toLowerCase() === pin.label.toLowerCase()
);
if (index !== -1) {
const pins = [...currentPins];
stored = mergePin(
{
...currentPins[index]!,
id: currentPins[index]!.id ?? randomUUID(),
},
pin
);
created = false;
pins[index] = stored;
return pins;
}
if (currentPins.length >= MAX_PINS) {
throw new AgentError(`Maximum of ${MAX_PINS} pins reached.`, 400);
}
stored = clearBlankPinFields({ ...pin, id: pin.id ?? randomUUID() });
created = true;
return [...currentPins, stored];
const result = applyPinSpec(currentPins, pin);
stored = result.stored;
created = result.created;
return result.pins;
});

return {
Expand All@@ -1179,14 +1165,62 @@ export class AgentManager {
};
}

/**
* Write many pins in one transaction.
*
* The point is atomicity and a single round trip: applying N pins through
* `upsertPin` costs N transactions, N `getAgent` reads and N sidebar
* re-renders, and a failure halfway leaves the set half-applied.
*
* In `replace` mode the named group is rebuilt to contain exactly `specs`,
* in order. There is deliberately no whole-list replace: every destructive
* batch has to name the group it is allowed to clear, so no call can remove
* a pin the agent forgot to restate.
*/
async upsertPins(
id: string,
specs: PinSpec[],
options: { mode?: "merge" | "replace"; group?: string } = {}
): Promise<{ agent: AgentRecord }> {
const mode = options.mode ?? "merge";
if (mode === "replace" && !options.group?.trim()) {
throw new AgentError(
"Replace mode requires a group to scope the replacement to.",
400
);
}

await this.mutatePins(id, (currentPins) =>
mode === "replace"
? replacePinGroup(currentPins, options.group!, specs).pins
: applyPinSpecs(currentPins, specs).pins
);

return { agent: (await this.getAgent(id)) as AgentRecord };
}

async deletePinById(id: string, pinId: string): Promise<AgentRecord> {
await this.mutatePins(id, (currentPins) => {
const pins = currentPins.filter((p) => p.id !== pinId);
if (pins.length === currentPins.length) {
throw new AgentError("Pin not found.", 404);
}
return pins;
});
await this.mutatePins(id, (currentPins) =>
removePinsByIds(currentPins, [pinId])
);

return (await this.getAgent(id)) as AgentRecord;
}

/** Delete several pins by id in one transaction; every id must exist. */
async deletePinsByIds(id: string, pinIds: string[]): Promise<AgentRecord> {
await this.mutatePins(id, (currentPins) =>
removePinsByIds(currentPins, pinIds)
);

return (await this.getAgent(id)) as AgentRecord;
}

/** Clear an entire group in one transaction. */
async deletePinsByGroup(id: string, group: string): Promise<AgentRecord> {
await this.mutatePins(id, (currentPins) =>
removePinGroup(currentPins, group)
);

return (await this.getAgent(id)) as AgentRecord;
}
Expand Down
48 changes: 32 additions & 16 deletions apps/server/src/agents/pin-merge.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
import type { AgentPin } from "./types.js";

/**
* A pin whose type has been resolved to a concrete string but not yet checked
* against the allowed set. Merging happens before validation — the effective
* type can come from the stored pin — so these helpers work at this width and
* the caller narrows to `AgentPin` afterwards.
*/
export type DraftPin = Omit<AgentPin, "type"> & { type: string };

/** Decorations that an agent clears by sending an empty string. */
const CLEARABLE_FIELDS = ["caption", "group", "icon"] as const;

Expand All@@ -15,7 +23,7 @@ const SHORTCUT_ONLY_FIELDS = [
* Optional pin decorations are cleared by passing an empty string — there is
* no other way to remove one, since an omitted field means "leave as-is".
*/
export function clearBlankPinFields(pin: AgentPin): AgentPin {
export function clearBlankPinFields<T extends DraftPin>(pin: T): T {
const cleared = { ...pin };
for (const field of CLEARABLE_FIELDS) {
if (cleared[field] !== undefined && cleared[field]!.trim() === "") {
Expand All@@ -26,27 +34,35 @@ export function clearBlankPinFields(pin: AgentPin): AgentPin {
}

/**
* Merge an incoming pin onto the one already stored under the same label.
* Drop decorations the resolved type has no meaning for, and clear the ones
* the agent blanked.
*
* Every write ends here, create and update alike: "the resolved type governs
* which decorations survive" is one rule, and stating it per-branch is how a
* plain pin ends up stored carrying `confirm` that nothing can then remove
* (`variant`/`confirm`/`disabled` aren't clearable by empty string).
*/
export function finalizePin<T extends DraftPin>(pin: T): T {
const finalized = clearBlankPinFields(pin);
if (finalized.type !== "shortcut") {
for (const field of SHORTCUT_ONLY_FIELDS) delete finalized[field];
}
return finalized;
}

/**
* Merge an incoming pin onto the one already stored.
*
* Merge rather than replace: an agent re-pinning to change one thing (add a
* group, refresh a value) shouldn't have to restate every decoration or
* silently lose it. Fields the agent omits keep their stored value; fields it
* sends as an empty string are removed.
* sends as an empty string are removed. Callers run `finalizePin` on the
* result — including to strip shortcut-only fields when a pin is re-typed.
*/
export function mergePin(existing: AgentPin, incoming: AgentPin): AgentPin {
const merged = clearBlankPinFields({
export function mergePin(existing: AgentPin, incoming: DraftPin): DraftPin {
return {
...existing,
...incoming,
id: existing.id ?? incoming.id,
});

// Omitting a field means "keep it", which would otherwise let a shortcut's
// icon/variant/confirm/disabled ride along when the pin is re-typed as
// something else — stale state an agent could see in dispatch_list_pins
// and have no way to clear.
if (merged.type !== "shortcut") {
for (const field of SHORTCUT_ONLY_FIELDS) delete merged[field];
}

return merged;
};
}
Loading
Loading