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
11 changes: 10 additions & 1 deletion apps/daemon/src/dispatch/daemonDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
import type { IConfigPresenter } from "@argos/shared/presenter";
import { resolveDaemonVersion } from "../version";
import { diagnoseDaemonSchema, repairDaemonSchema } from "../host/daemonSchemaDiagnostics";
import { getPiToolDefinitions } from "../host/piToolCatalog";
import type {
IEventPublisher,
ProviderExecutionPort,
Expand Down Expand Up @@ -787,6 +788,9 @@ export function createDaemonDispatcher(
};
},
environmentId = "unknown",
orchestrationRuntime?: {
definitions(): unknown[];
},
): RouteDispatcher {
const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter));
const runtime: {
Expand Down Expand Up @@ -1730,8 +1734,13 @@ export function createDaemonDispatcher(

if (route === toolsListDefinitionsRoute.name) {
const input = toolsListDefinitionsRoute.input.parse(rawInput);
const orchestrationTools = orchestrationRuntime?.definitions() ?? [];
return toolsListDefinitionsRoute.output.parse({
tools: await mcpRuntime.listToolDefinitions(input.enabledMcpTools),
tools: [
...(await mcpRuntime.listToolDefinitions(input.enabledMcpTools)),
...getPiToolDefinitions(),
...orchestrationTools,
],
});
}

Expand Down
5 changes: 1 addition & 4 deletions apps/daemon/src/host/daemonScheduledTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ class DaemonScheduledTasksNotificationPresenter {
}

class DaemonScheduledTasksWindowPresenter {
readonly mainWindow = {
id: -1,
isDestroyed: () => false,
};
readonly mainWindow = null;

sendToWindow(): never {
throw new Error("Scheduled task prompt drafts require a desktop window");
Expand Down
132 changes: 132 additions & 0 deletions apps/daemon/src/host/piToolCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { MCPToolDefinition } from "@argos/shared/types/core/mcp";

// JSON-Schema-compatible property descriptor. `properties`/`items` can themselves
// describe nested objects (e.g. `edit.edits[]` with its own `properties` and
// `required`), so this is recursive rather than a flat `{ type, description }`.
type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "array" | "object" | "null";

type CatalogProperty = {
type: JsonSchemaType;
description: string;
items?: CatalogProperty;
properties?: Record<string, CatalogProperty>;
required?: string[];
enum?: (string | number)[];
};

type CatalogEntry = {
description: string;
properties: Record<string, CatalogProperty>;
required?: string[];
};

const PI_TOOL_CATALOG: Record<string, CatalogEntry> = {
read: {
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. Text output is truncated to 2000 lines or 50KB; use offset/limit for large files.",
properties: {
path: { type: "string", description: "Path to the file to read." },
offset: { type: "number", description: "Offset into the file (in lines) to start reading from." },
limit: { type: "number", description: "Maximum number of lines to read." },
},
required: ["path"],
},
bash: {
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to the last 2000 lines or 50KB; optionally provide a timeout in seconds.",
properties: {
command: { type: "string", description: "The bash command to execute." },
timeout: { type: "number", description: "Optional timeout in seconds." },
},
required: ["command"],
},
edit: {
description:
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the file. Merge overlapping changes into one edit.",
properties: {
path: { type: "string", description: "Path of the file to edit." },
edits: {
type: "array",
items: {
type: "object",
description: "A single replacement: oldText to find and newText to replace it with.",
properties: {
oldText: {
type: "string",
description: "Exact text to find in the file. Must be unique and non-overlapping.",
},
newText: { type: "string", description: "Text to replace the matched region with." },
},
required: ["oldText", "newText"],
},
description: "The edits to apply.",
},
},
required: ["path", "edits"],
},
write: {
description:
"Write content to a file, creating it if it doesn't exist and overwriting if it does. Automatically creates parent directories.",
properties: {
path: { type: "string", description: "Path of the file to write." },
content: { type: "string", description: "Full content to write to the file." },
},
required: ["path", "content"],
},
grep: {
description:
"Search file contents for a pattern, returning matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB.",
properties: {
pattern: { type: "string", description: "Pattern to search for." },
path: { type: "string", description: "File or directory to search." },
glob: { type: "string", description: "Optional glob to filter files." },
ignoreCase: { type: "boolean", description: "Whether to ignore case." },
literal: { type: "boolean", description: "Match literally rather than as a regex." },
context: { type: "number", description: "Lines of context to include around matches." },
limit: { type: "number", description: "Maximum number of matches." },
},
required: ["pattern", "path"],
},
find: {
description:
"Search for files by glob pattern, returning paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB.",
properties: {
pattern: { type: "string", description: "Glob pattern to match." },
path: { type: "string", description: "Directory to search." },
limit: { type: "number", description: "Maximum number of results." },
},
required: ["pattern", "path"],
},
ls: {
description:
"List directory contents, sorted alphabetically with a '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB.",
properties: {
path: { type: "string", description: "Directory to list." },
limit: { type: "number", description: "Maximum number of entries." },
},
required: ["path"],
},
};

const PI_SERVER = {
name: "pi",
icons: "",
description: "Pi coding-agent built-in tools",
} as const;

export function getPiToolDefinitions(): MCPToolDefinition[] {
return Object.entries(PI_TOOL_CATALOG).map(([name, entry]) => ({
type: "function",
source: "agent",
function: {
name,
description: entry.description,
parameters: {
type: "object",
properties: entry.properties,
required: entry.required,
},
},
server: { ...PI_SERVER },
}));
}
1 change: 1 addition & 0 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ export async function startDaemon(options?: {
providerImportService,
db,
environmentId,
orchestrationRuntime,
);
setRouteDispatcher(dispatcher);

Expand Down
1 change: 1 addition & 0 deletions apps/daemon/src/transport/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export async function dispatchRoute(route: string, input: unknown): Promise<Rout
const parsedOutput = contract.output.parse(output);
return ensureJsonSerializableRouteResponse({ ok: true, output: parsedOutput });
} catch (error) {
console.error(`[dispatch] Route "${String(route)}" failed:`, error);
const message = error instanceof Error ? error.message : String(error);
const code = message.includes("validation") ? "validation_error" : "dispatch_error";
return { ok: false, error: { code, message } };
Expand Down
16 changes: 14 additions & 2 deletions apps/daemon/test/daemonScheduledTasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe("DaemonScheduledTasks", () => {
});
});

it("rejects draft prompt tasks because headless daemon has no desktop window", async () => {
it("falls back to notification-only for draft prompt tasks (headless daemon has no window)", async () => {
const task = {
id: "task-3",
name: "Draft",
Expand All @@ -108,6 +108,18 @@ describe("DaemonScheduledTasks", () => {
} as const;
const harness = createHarness([task as never]);

await expect(harness.runtime.fireNow("task-3")).rejects.toThrow("desktop window");
// The headless daemon has no desktop window, so draft (non-auto-send) prompt
// tasks can't open a draft UI. They must not crash — they fall through to a
// notification so the user still sees the task fired.
const result = await harness.runtime.fireNow("task-3");

expect(harness.sessionRepository.create).not.toHaveBeenCalled();
expect(harness.providerExecutionPort.sendMessage).not.toHaveBeenCalled();
expect(harness.eventPublisher.publish).toHaveBeenCalledWith("scheduledTasks.notification", {
id: "scheduled:task-3",
title: "Draft",
body: "Open the draft",
});
expect(result.task.lastFiredAt).toEqual(expect.any(Number));
});
});
107 changes: 98 additions & 9 deletions apps/daemon/test/daemonToolDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,107 @@ describe("daemon tool definitions", () => {
agentWorkspacePath: "/tmp/project",
conversationId: "session-1",
}),
).resolves.toEqual({
tools: [
expect.objectContaining({
name: "test-tool",
description: "Test tool",
enabledMcpTools: ["server-a"],
}),
],
});
).resolves.toEqual(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
name: "test-tool",
description: "Test tool",
enabledMcpTools: ["server-a"],
}),
]),
}),
);
expect(mcpRuntime.listToolDefinitions).toHaveBeenCalledWith(["server-a"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});

it("appends orchestration tools to the daemon tool definitions", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "argos-daemon-tools-orchestration-"));
try {
const configPresenter = new DaemonConfigPresenter(path.join(root, "config"), path.join(root, "data"));
const mcpRuntime = {
listToolDefinitions: vi.fn(async () => [
{
name: "mcp-tool",
description: "MCP tool",
server: { name: "server-a" },
},
]),
};
const orchestrationRuntime = {
definitions: vi.fn(() => [
{
source: "agent",
function: { name: "argos_projects_list", description: "List projects." },
server: { name: "argos-orchestration", description: "First-party tools" },
},
]),
};

const dispatcher = createDaemonDispatcher(
configPresenter as any,
undefined,
undefined,
undefined,
undefined,
mcpRuntime as any,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"test-env",
orchestrationRuntime as any,
);
const result = (await dispatcher(toolsListDefinitionsRoute.name, {})) as { tools: unknown[] };
expect(result.tools).toContainEqual(expect.objectContaining({ name: "mcp-tool" }));
const orchestrationTool = result.tools.find(
(tool) => (tool as { server?: { name?: string } }).server?.name === "argos-orchestration",
) as { source?: string };
expect(orchestrationTool).toBeDefined();
expect(orchestrationTool.source).toBe("agent");

// Every Pi tool should be present, tagged as an agent source, and carry a
// well-formed function schema (name + description + object parameters).
const piTools = result.tools.filter(
(tool) => (tool as { server?: { name?: string } }).server?.name === "pi",
) as Array<{
source?: string;
function?: { name?: string; description?: string; parameters?: { type?: string; properties?: unknown } };
}>;
const piByName = new Map(piTools.map((tool) => [tool.function?.name, tool]));
const expectedPiTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
expect(piTools.map((tool) => tool.function?.name).sort()).toEqual([...expectedPiTools].sort());
for (const name of expectedPiTools) {
const tool = piByName.get(name);
expect(tool).toBeDefined();
expect(tool!.source).toBe("agent");
expect(tool!.function?.description).toBeTruthy();
expect(tool!.function?.parameters?.type).toBe("object");
expect(tool!.function?.parameters?.properties).toBeDefined();
}

// The edit tool's `edits[]` must describe its inner object shape so callers
// can construct valid calls (regression for the under-typed catalog entry).
const editTool = piByName.get("edit");
const editParams = editTool!.function!.parameters as {
properties?: {
edits?: { type?: string; items?: { properties?: Record<string, unknown>; required?: string[] } };
};
};
const edits = editParams.properties?.edits;
expect(edits?.type).toBe("array");
expect(edits?.items?.properties?.oldText).toBeDefined();
expect(edits?.items?.properties?.newText).toBeDefined();
expect(edits?.items?.required).toEqual(["oldText", "newText"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
12 changes: 12 additions & 0 deletions apps/desktop/src/main/presenter/configPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2838,6 +2838,12 @@ export class ConfigPresenter implements IConfigPresenter {
}
// Listen for system theme changes
nativeTheme.on("updated", () => {
// Re-sync the Windows window controls overlay symbol colors when the theme flips
try {
presenter.windowPresenter.syncWindowTitleBarAppearance();
} catch (error) {
console.error("Failed to sync window controls overlay theme:", error);
}
// Only notify the renderer of system theme changes when the theme is set to "system"
if (nativeTheme.themeSource === "system") {
eventBus.sendToMain(SYSTEM_EVENTS.SYSTEM_THEME_UPDATED, nativeTheme.shouldUseDarkColors);
Expand All @@ -2854,6 +2860,12 @@ export class ConfigPresenter implements IConfigPresenter {
async setTheme(theme: "dark" | "light" | "system"): Promise<boolean> {
nativeTheme.themeSource = theme;
this.setSetting("appTheme", theme);
// Re-sync the Windows native window controls overlay after an explicit theme change
try {
presenter.windowPresenter.syncWindowTitleBarAppearance();
} catch (error) {
console.error("Failed to sync window title bar appearance:", error);
}
// Notify all windows that the theme has changed
eventBus.send(CONFIG_EVENTS.THEME_CHANGED, SendTarget.ALL_WINDOWS, theme);

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/presenter/hooksNotifications/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export const normalizeHooksNotificationsConfig = (input: unknown): HooksNotifica
}

const defaults = createDefaultHooksNotificationsConfig();
if (input === undefined || input === null) {
return defaults;
}
const parsed = HooksNotificationsSchema.safeParse(input);
if (!parsed.success) {
log.warn("[HooksNotifications] Invalid config, using defaults:", parsed.error?.message);
Expand Down
Loading
Loading