From 520ef1d4830006d80c4c9a9bf18c431ed7a6a7e1 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 06:56:50 +0300 Subject: [PATCH 1/2] The project's page counts its skills and offers Add skills --- packages/agent-data/package.json | 2 +- packages/agent-driver/claude/package.json | 2 +- packages/agent-driver/codex/package.json | 2 +- packages/agent-driver/core/package.json | 2 +- .../agent-driver/github-actions/package.json | 2 +- packages/agent-runner/package.json | 2 +- packages/files/package.json | 2 +- packages/init/src/branch.test.ts | 53 ++++ packages/init/src/branch.ts | 51 ++++ packages/init/src/index.ts | 1 + packages/openagent/DECISIONS.md | 15 ++ packages/openagent/dashboard/App.tsx | 2 + .../components/CommandsMenu.test.tsx | 9 + .../dashboard/components/CommandsMenu.tsx | 5 +- .../dashboard/components/ProjectHome.test.tsx | 1 + .../dashboard/components/ProjectHome.tsx | 6 + .../components/ProjectSkills.test.tsx | 192 +++++++++++++++ .../dashboard/components/ProjectSkills.tsx | 228 ++++++++++++++++++ .../dashboard/lib/use-project-launcher.ts | 8 +- packages/openagent/dashboard/rpc/skills.ts | 12 + packages/openagent/package.json | 3 +- packages/openagent/src/dashboard-rpc/index.ts | 4 +- .../openagent/src/dashboard-rpc/projects.ts | 8 +- .../src/dashboard-rpc/skills.test.ts | 143 +++++++++++ .../openagent/src/dashboard-rpc/skills.ts | 102 ++++++++ .../openagent/src/dashboard/start-branch.ts | 15 ++ packages/openagent/src/project-commands.ts | 2 + pnpm-lock.yaml | 3 + 28 files changed, 862 insertions(+), 15 deletions(-) create mode 100644 packages/init/src/branch.test.ts create mode 100644 packages/init/src/branch.ts create mode 100644 packages/openagent/dashboard/components/ProjectSkills.test.tsx create mode 100644 packages/openagent/dashboard/components/ProjectSkills.tsx create mode 100644 packages/openagent/dashboard/rpc/skills.ts create mode 100644 packages/openagent/src/dashboard-rpc/skills.test.ts create mode 100644 packages/openagent/src/dashboard-rpc/skills.ts create mode 100644 packages/openagent/src/dashboard/start-branch.ts diff --git a/packages/agent-data/package.json b/packages/agent-data/package.json index 0ff7dfb84..23f6e533a 100644 --- a/packages/agent-data/package.json +++ b/packages/agent-data/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-data", - "version": "0.0.2", + "version": "0.0.3", "description": "What OpenAgent's packages share about a project: making a folder a project, and a branch of the project's repository used as a file store for coding-agent skills. Checked out under .branches/; every write is one sync-commit-push cycle that re-applies when it loses a race, from a long-lived process or one-shot from any clone.", "license": "MIT", "repository": { diff --git a/packages/agent-driver/claude/package.json b/packages/agent-driver/claude/package.json index 6c74a3bd5..4dd8e0dbf 100644 --- a/packages/agent-driver/claude/package.json +++ b/packages/agent-driver/claude/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-driver-claude", - "version": "0.0.2", + "version": "0.0.3", "description": "Claude Code behind the agent-driver contract, on this machine; plus the reader of the account's quota.", "license": "MIT", "repository": { diff --git a/packages/agent-driver/codex/package.json b/packages/agent-driver/codex/package.json index e8c55ccb9..98b652e8f 100644 --- a/packages/agent-driver/codex/package.json +++ b/packages/agent-driver/codex/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-driver-codex", - "version": "0.0.2", + "version": "0.0.3", "description": "Codex behind the agent-driver contract, on this machine.", "license": "MIT", "repository": { diff --git a/packages/agent-driver/core/package.json b/packages/agent-driver/core/package.json index 288114e35..68e37c63e 100644 --- a/packages/agent-driver/core/package.json +++ b/packages/agent-driver/core/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-driver", - "version": "0.0.2", + "version": "0.0.3", "description": "The contract for driving a coding-agent CLI as a black box: start a session in a directory, prompt it for one full turn, stream what it does, resume it later; the pieces every adapter shares, and a scripted fake for tests. Each driver ships as @openagt/agent-driver-.", "license": "MIT", "repository": { diff --git a/packages/agent-driver/github-actions/package.json b/packages/agent-driver/github-actions/package.json index 424a703ec..2a3bb042f 100644 --- a/packages/agent-driver/github-actions/package.json +++ b/packages/agent-driver/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-driver-github-actions", - "version": "0.0.2", + "version": "0.0.3", "description": "A coding agent on a GitHub Actions runner behind the agent-driver contract: each turn one workflow run, its transcript replayed. Claude Code today.", "license": "MIT", "repository": { diff --git a/packages/agent-runner/package.json b/packages/agent-runner/package.json index 3afcd7b17..f7b8649cb 100644 --- a/packages/agent-runner/package.json +++ b/packages/agent-runner/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/agent-runner", - "version": "0.0.2", + "version": "0.0.3", "description": "Run one coding agent from start to end: its own checkout, one prompt, the agent's own loop, a run record on the project's agent-data branch; resume a run that ended on a question; and the sweep a scheduler runs to record the runs whose process died.", "license": "MIT", "repository": { diff --git a/packages/files/package.json b/packages/files/package.json index cc89e0d3c..9b9f136e6 100644 --- a/packages/files/package.json +++ b/packages/files/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/files", - "version": "0.0.2", + "version": "0.0.3", "description": "The Files module for the OpenAgent dashboard: a project's or a run's files as a tree, what the run changed marked, any file previewed on hover.", "license": "MIT", "repository": { diff --git a/packages/init/src/branch.test.ts b/packages/init/src/branch.test.ts new file mode 100644 index 000000000..ff261d594 --- /dev/null +++ b/packages/init/src/branch.test.ts @@ -0,0 +1,53 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { commitSkills, skillsOn, uncommittedSkills } from './branch.js' +import { applyChange, commitChange } from './change.js' +import { folder, gone, run } from './test-project.js' + +test('a skill written into the folder is not on the branch until it is committed; then it is, by its text and by its link', async () => { + const root = await folder(true) + try { + assert.deepEqual([...(await skillsOn(root, 'HEAD'))], []) + const changed = await applyChange(root, { write: ['tickets', 'queue'] }) + assert.deepEqual([...(await skillsOn(root, 'HEAD'))], [], 'written, not committed') + assert.equal(await uncommittedSkills(root), 4, 'two texts and two links') + await commitChange(root, changed) + assert.deepEqual([...(await skillsOn(root, 'HEAD'))].sort(), ['queue', 'tickets']) + assert.equal(await uncommittedSkills(root), 0) + + // A project's own skill, held as a real folder where Claude Code reads, and one outside init's list. + await mkdir(join(root, '.claude/skills/deploy'), { recursive: true }) + await writeFile(join(root, '.claude/skills/deploy/SKILL.md'), '---\nname: deploy\n---\nOurs.\n') + await run(root, 'add', '-A') + await run(root, 'commit', '-q', '-m', 'ours') + assert.deepEqual([...(await skillsOn(root, 'HEAD'))].sort(), ['deploy', 'queue', 'tickets']) + assert.deepEqual([...(await skillsOn(root, 'no-such-branch'))], [], 'a branch that is not there has none') + } finally { + await gone(root) + } +}) + +test('the dashboard\'s commit takes every skill file that stands uncommitted, a deleted one too, and nothing else', async () => { + const root = await folder(true) + try { + await commitChange(root, await applyChange(root, { write: ['tickets', 'ux'] })) + await applyChange(root, { write: ['plan'], remove: ['ux'] }) + await writeFile(join(root, '.agents/skills/tickets/SKILL.md'), 'changed by hand\n') + await writeFile(join(root, 'README.md'), '# edited\n') + await writeFile(join(root, 'staged.txt'), 'theirs\n') + await run(root, 'add', 'staged.txt') + assert.equal(await uncommittedSkills(root), 5, 'plan\'s text and link, ux\'s text and link, tickets\' text') + + const committed = await commitSkills(root) + assert.ok(committed.ok && committed.committed) + assert.equal(await run(root, 'show', '--format=%s', '--name-status', 'HEAD'), 'Update OpenAgent skills\n\nA\t.agents/skills/plan/SKILL.md\nM\t.agents/skills/tickets/SKILL.md\nD\t.agents/skills/ux/SKILL.md\nA\t.claude/skills/plan\nD\t.claude/skills/ux\n') + assert.equal(await run(root, 'status', '--porcelain'), ' M README.md\nA staged.txt\n') + assert.equal(await uncommittedSkills(root), 0) + assert.deepEqual(await commitSkills(root), { ok: true, committed: false }, 'nothing left to commit') + await rm(join(root, 'staged.txt')) + } finally { + await gone(root) + } +}) diff --git a/packages/init/src/branch.ts b/packages/init/src/branch.ts new file mode 100644 index 000000000..22cd38cef --- /dev/null +++ b/packages/init/src/branch.ts @@ -0,0 +1,51 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' +import { SKILL_NAMES } from './catalogue.js' +import { commitPaths, type CommitOutcome } from './commit.js' +import { LINKS_DIR, TEXTS_DIR } from './project.js' + +/** + * A project's skills as git has them (#2023): which are on a branch, and which stand written or + * deleted in the folder with no commit yet. An agent's checkout starts from a branch, so a skill + * written into the folder reaches agents only once it is on that branch; until then a dashboard + * says it is waiting, and offers the commit. + */ + +/** The names of the skills whose text is on `ref`, in either folder: the text itself, or a link there to a text that is. */ +export async function skillsOn(root: string, ref: string, git: GitRunner = nodeGitRunner()): Promise> { + const listed = await git(['ls-tree', '-r', '-z', '--name-only', ref, '--', TEXTS_DIR, LINKS_DIR], root).catch(() => '') + const paths = new Set(listed.split('\0').filter(Boolean)) + const on = new Set() + const texts = (dir: string): string[] => [...paths].flatMap(path => (path.startsWith(`${dir}/`) && path.endsWith('/SKILL.md') && path.split('/').length === 4 ? [path.split('/')[2]!] : [])) + for (const name of [...texts(TEXTS_DIR), ...texts(LINKS_DIR)]) on.add(name) + // A link is one entry of the tree, named as the skill: it counts where the text it leads to is on the branch too. + for (const path of paths) { + const [, , name, more] = path.split('/') + if (path.startsWith(`${LINKS_DIR}/`) && name !== undefined && more === undefined && paths.has(`${TEXTS_DIR}/${name}/SKILL.md`)) on.add(name) + } + return on +} + +/** The paths git may hold for the skills of `init`'s list, from the project's root: each text, each link, and a text in a folder of Claude Code's own where there is no link. */ +async function skillPaths(root: string): Promise { + const paths: string[] = [] + for (const name of SKILL_NAMES) { + paths.push(`${TEXTS_DIR}/${name}/SKILL.md`) + // Git takes a link as one path, and refuses a path that goes through one. + const link = await lstat(join(root, LINKS_DIR, name)).catch(() => undefined) + paths.push(link?.isDirectory() ? `${LINKS_DIR}/${name}/SKILL.md` : `${LINKS_DIR}/${name}`) + } + return paths +} + +/** How many skill files stand written, changed or deleted in the folder with no commit yet. */ +export async function uncommittedSkills(root: string, git: GitRunner = nodeGitRunner()): Promise { + const status = await git(['status', '--porcelain', '-z', '-uall', '--', ...(await skillPaths(root))], root).catch(() => '') + return status.split('\0').filter(Boolean).length +} + +/** One commit of every skill file that stands uncommitted, and nothing else: what a dashboard's "Commit" does. Never a push. */ +export async function commitSkills(root: string, git: GitRunner = nodeGitRunner()): Promise { + return commitPaths(root, await skillPaths(root), 'Update OpenAgent skills\n', git) +} diff --git a/packages/init/src/index.ts b/packages/init/src/index.ts index f8e6e9720..d72998de9 100644 --- a/packages/init/src/index.ts +++ b/packages/init/src/index.ts @@ -3,3 +3,4 @@ export { readProject, held, skillCount, standingOf, TEXTS_DIR, LINKS_DIR, type P export { applyChange, commitChange, commitMessage, isKnown, type Change, type Changed } from './change.js' export { type CommitOutcome } from './commit.js' export { stamped, unstamped, olderThan } from './skill-file.js' +export { skillsOn, uncommittedSkills, commitSkills } from './branch.js' diff --git a/packages/openagent/DECISIONS.md b/packages/openagent/DECISIONS.md index 55f7e4571..ca3eec895 100644 --- a/packages/openagent/DECISIONS.md +++ b/packages/openagent/DECISIONS.md @@ -103,6 +103,21 @@ already made, and lists it in its pull request; for anything else it proposes an among a project's own dependencies is not read: OpenAgent's copy and its rule are the only ones. Picked over a project's own copy winning, which gave one package two ways into a project. +- "Add project" stays small: it writes no skill. The project's page then says how many + skills it has ("This project has 4 of 25 skills"), with an "Add skills" button that + opens the same list with ticks `npx @openagt/init` shows in a terminal, through that + package's own code, so the two cannot drift apart. Picked over "Add project" writing + the skills itself, which put files into every project a person adds, and over only + pointing to the terminal. +- When a skill in a project has a newer text, the project's page says so in one line ("3 + skills have a newer text") with an "Update" button; the texts are written only on that + press, and the person reads the change in git. Picked over saying nothing until asked, + and over writing every text again unasked. +- The dashboard offers the same commit init does: one commit of the skill files that + stand uncommitted, those alone, on the branch the folder is on, never a push. While a + skill's text is in the folder but not yet on the branch agents start from, the page + says so ("tickets: waiting to reach main"), and the launcher shows its command as + waiting and does not take it, so it never offers a command the agent does not have. - A module may bring a section of the Settings page, shown after OpenAgent's own sections, where a project has its package. It reads and writes through the package's own command. Picked over OpenAgent's own sections knowing a package's file and a hook line diff --git a/packages/openagent/dashboard/App.tsx b/packages/openagent/dashboard/App.tsx index e30af79e8..b2c513a03 100644 --- a/packages/openagent/dashboard/App.tsx +++ b/packages/openagent/dashboard/App.tsx @@ -385,6 +385,8 @@ export function App() { setProjectsKey(k => k + 1) showDashboard() }} + // A skill written or deleted gives or takes a page: read the projects and their modules again now. + onSkillsChanged={() => setProjectsKey(k => k + 1)} /> ) } diff --git a/packages/openagent/dashboard/components/CommandsMenu.test.tsx b/packages/openagent/dashboard/components/CommandsMenu.test.tsx index 572c8bb61..70e724e05 100644 --- a/packages/openagent/dashboard/components/CommandsMenu.test.tsx +++ b/packages/openagent/dashboard/components/CommandsMenu.test.tsx @@ -42,6 +42,15 @@ describe('CommandsMenu', () => { expect(onLoad).toHaveBeenCalledWith('/work-queue ', '/work-queue') }) + test('a command whose skill has not reached the branch agents start from says so, and cannot be picked', () => { + const { onLoad } = mount({ commands: [{ name: 'plan', waiting: 'main' }, { name: 'ux' }] }) + expect(screen.getByText('waiting to reach main')).toBeTruthy() + fireEvent.click(screen.getByText('/plan')) + expect(onLoad).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('/ux')) + expect(onLoad).toHaveBeenCalledWith('/ux ', '/ux') + }) + test('a project with no commands says so', () => { mount({ commands: [] }) expect(screen.getByText('This project has no commands.')).toBeTruthy() diff --git a/packages/openagent/dashboard/components/CommandsMenu.tsx b/packages/openagent/dashboard/components/CommandsMenu.tsx index 514627236..7eda1318e 100644 --- a/packages/openagent/dashboard/components/CommandsMenu.tsx +++ b/packages/openagent/dashboard/components/CommandsMenu.tsx @@ -107,12 +107,13 @@ export function CommandsMenu({ Commands {commands.length === 0 &&
This project has no commands.
} {commands.map(c => { + // A skill written into the folder and not yet on the branch agents start from: an agent started now does not have its command. const itemProps = { - disabled: busy, + disabled: busy || c.waiting !== undefined, onClick: () => onLoad(`/${c.name} `, `/${c.name}`), className: 'items-start', } - const label = + const label = if (!c.description) return ( diff --git a/packages/openagent/dashboard/components/ProjectHome.test.tsx b/packages/openagent/dashboard/components/ProjectHome.test.tsx index e32c2272c..177bb7e46 100644 --- a/packages/openagent/dashboard/components/ProjectHome.test.tsx +++ b/packages/openagent/dashboard/components/ProjectHome.test.tsx @@ -9,6 +9,7 @@ vi.mock('../rpc/reads.js', () => ({ onDocs })) // The sections are tested on their own; here they are stand-ins. vi.mock('./ProjectActions.js', () => ({ ProjectActions: () =>
actions
})) vi.mock('./ProjectErrorBanner.js', () => ({ ProjectErrorBanner: () => null })) +vi.mock('./ProjectSkills.js', () => ({ ProjectSkillsLine: () =>
skills line
})) vi.mock('./StartAgentForm.js', () => ({ StartAgentForm: () =>
start form
})) vi.mock('./AgentOverview.js', () => ({ AgentOverview: () =>
overview
})) vi.mock('./OpenQuestions.js', () => ({ OpenQuestions: () =>
open questions
})) diff --git a/packages/openagent/dashboard/components/ProjectHome.tsx b/packages/openagent/dashboard/components/ProjectHome.tsx index c4c9250fa..e94772531 100644 --- a/packages/openagent/dashboard/components/ProjectHome.tsx +++ b/packages/openagent/dashboard/components/ProjectHome.tsx @@ -2,6 +2,7 @@ import type { OpenAgentEvent, ProjectError } from '../../src/index.js' import { StartAgentForm } from './StartAgentForm.js' import { ProjectActions } from './ProjectActions.js' import { ProjectErrorBanner } from './ProjectErrorBanner.js' +import { ProjectSkillsLine } from './ProjectSkills.js' import { AgentOverview } from './AgentOverview.js' import { OpenQuestions } from './OpenQuestions.js' import { ScrollArea } from './ui/scroll-area.js' @@ -28,6 +29,7 @@ export function ProjectHome({ onOpenAgent, errors, onProjectRemoved, + onSkillsChanged, }: { projectId: string /** The project's name, for the launcher's chip; absent until the projects are read. */ @@ -49,6 +51,8 @@ export function ProjectHome({ errors?: ProjectError[] | undefined /** Told once the project is off the list, so the shell leaves its page. */ onProjectRemoved?: (() => void) | undefined + /** Told once the project's skills were written, deleted or committed: its pages may have changed. */ + onSkillsChanged?: (() => void) | undefined }) { return ( <> @@ -56,6 +60,8 @@ export function ProjectHome({ {/* Above everything, because an agent started on a project whose agent-data branch cannot reach origin (#1599) works from stale tickets and a queue nobody else will see. */} + {/* The project's skills, and the door to add some: a new project has almost none (#2023). */} + {/* Laid out as an agent's page is: what waits for the person fills the page from the top, in the chat's column, and scrolls there; the box to start an agent stays at the bottom, in the place and at the width of an agent's message box. */} diff --git a/packages/openagent/dashboard/components/ProjectSkills.test.tsx b/packages/openagent/dashboard/components/ProjectSkills.test.tsx new file mode 100644 index 000000000..150c4e651 --- /dev/null +++ b/packages/openagent/dashboard/components/ProjectSkills.test.tsx @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { ProjectSkills } from '../rpc/skills.js' + +const onProjectSkills = vi.hoisted(() => vi.fn()) +const sendChangeSkills = vi.hoisted(() => vi.fn()) +const sendCommitSkills = vi.hoisted(() => vi.fn()) +vi.mock('../rpc/skills.js', () => ({ onProjectSkills, sendChangeSkills, sendCommitSkills })) + +const { ProjectSkillsLine } = await import('./ProjectSkills.js') + +afterEach(() => { + cleanup() + onProjectSkills.mockReset() + sendChangeSkills.mockReset() + sendCommitSkills.mockReset() +}) + +type Standing = ProjectSkills['groups'][number]['skills'][number]['standing'] +const skill = (name: string, standing: Standing = 'absent', waiting = false) => ({ name, description: `What ${name} does.`, standing, waiting }) + +/** A project's skills as the daemon answers them; `over` changes the parts a test is about. */ +function skills(over: Partial = {}): ProjectSkills { + return { + has: 3, + of: 25, + groups: [ + { title: 'Tickets and queue', ticked: true, skills: [skill('tickets'), skill('queue')] }, + { title: 'Needs its own setup', ticked: false, skills: [skill('browser')] }, + ], + scheduler: false, + startBranch: 'main', + uncommitted: 0, + git: true, + ...over, + } +} + +const box = (name: string): HTMLElement => screen.getByRole('checkbox', { name }) +const isTicked = (name: string): boolean => box(name).getAttribute('aria-checked') === 'true' + +describe('ProjectSkillsLine', () => { + test('says how many skills the project has, and nothing more when there is nothing to do', async () => { + onProjectSkills.mockResolvedValue(skills()) + render() + expect(await screen.findByText(/This project has 3 of 25 skills\./)).toBeTruthy() + expect(onProjectSkills).toHaveBeenCalledWith('p1') + expect(screen.getByRole('button', { name: 'Add skills' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Update' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Commit' })).toBeNull() + expect(screen.queryByText(/waiting to reach/)).toBeNull() + }) + + test('a project that is unknown here shows no line', async () => { + onProjectSkills.mockResolvedValue(null) + const { container } = render() + await waitFor(() => expect(onProjectSkills).toHaveBeenCalled()) + expect(container.textContent).toBe('') + }) + + test('a newer text is told, and Update writes those skills again and nothing else', async () => { + onProjectSkills.mockResolvedValue( + skills({ has: 6, groups: [{ title: 'Tickets and queue', ticked: true, skills: [skill('tickets', 'newer'), skill('queue', 'newer'), skill('plan', 'changed')] }] }), + ) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [] }) + const onChanged = vi.fn() + render() + expect(await screen.findByText(/2 skills have a newer text\./)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Update' })) + await waitFor(() => expect(sendChangeSkills).toHaveBeenCalledWith('p1', { write: ['tickets', 'queue'] })) + await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)) + }) + + test('files not committed are told, Commit commits them, and the line then says nothing was pushed', async () => { + onProjectSkills.mockResolvedValueOnce(skills({ has: 4, uncommitted: 2 })).mockResolvedValue(skills({ has: 4, uncommitted: 0 })) + sendCommitSkills.mockResolvedValue({ ok: true, committed: true, commit: 'abc1234' }) + render() + expect(await screen.findByText(/2 skill files are not committed\./)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Commit' })) + await waitFor(() => expect(sendCommitSkills).toHaveBeenCalledWith('p1')) + expect(await screen.findByText('Committed as abc1234. Nothing was pushed.')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Commit' })).toBeNull() + }) + + test('a commit that is refused says why and keeps the button', async () => { + onProjectSkills.mockResolvedValue(skills({ uncommitted: 1 })) + sendCommitSkills.mockResolvedValue({ ok: false, error: 'git ignores .claude/skills/tickets: an ignore rule of this project, or of yours, covers it' }) + render() + expect(await screen.findByText(/1 skill file is not committed\./)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Commit' })) + expect(await screen.findByText(/git ignores \.claude\/skills\/tickets/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Commit' })).toBeTruthy() + }) + + test('skills in the folder that are not yet on the branch agents start from are named as waiting', async () => { + onProjectSkills.mockResolvedValue(skills({ has: 5, groups: [{ title: 'Tickets and queue', ticked: true, skills: [skill('tickets', 'current', true), skill('queue', 'current', true), skill('plan', 'current')] }] })) + const { container } = render() + await screen.findByText(/This project has 5 of 25 skills\./) + expect(container.textContent).toContain('tickets, queue: waiting to reach main. An agent started now does not have them.') + }) + + test('many waiting skills are counted, not named', async () => { + onProjectSkills.mockResolvedValue(skills({ has: 8, groups: [{ title: 'Tickets and queue', ticked: true, skills: ['tickets', 'queue', 'plan', 'triage', 'ux'].map(name => skill(name, 'current', true)) }] })) + const { container } = render() + await screen.findByText(/This project has 8 of 25 skills\./) + expect(container.textContent).toContain('5 skills: waiting to reach main. An agent started now does not have them.') + }) +}) + +describe('Add skills', () => { + test('in a project with no skills yet the default picks are ticked: every group but the one that needs a setup of its own, and the scheduler', async () => { + onProjectSkills.mockResolvedValue(skills()) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [] }) + const onChanged = vi.fn() + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Tickets and queue')).toBeTruthy() + expect(within(dialog).getByText('What tickets does.')).toBeTruthy() + expect([isTicked('tickets'), isTicked('queue'), isTicked('browser'), isTicked('scheduler')]).toEqual([true, true, false, true]) + expect(within(dialog).getByText('2 skills to write, the scheduler on.')).toBeTruthy() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => expect(sendChangeSkills).toHaveBeenCalledWith('p1', { write: ['tickets', 'queue'], remove: [], scheduler: true })) + expect(await within(dialog).findByText(/Wrote 2 skills in/)).toBeTruthy() + expect(dialog.textContent).toContain('tickets, queue') + expect(dialog.textContent).toContain('Your agents get these skills once they are on main.') + await waitFor(() => expect(onChanged).toHaveBeenCalled()) + }) + + test('opened again the ticks show what the project has; a tick removed deletes, a tick added writes, and the scheduler is left alone', async () => { + onProjectSkills.mockResolvedValue( + skills({ has: 5, groups: [{ title: 'Tickets and queue', ticked: true, skills: [skill('tickets', 'current'), skill('queue', 'changed'), skill('plan')] }, { title: 'Needs its own setup', ticked: false, skills: [skill('browser')] }] }), + ) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['browser'], removed: ['tickets'], left: [{ path: '.agents/skills/tickets', reason: 'it holds other files' }] }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + const dialog = await screen.findByRole('dialog') + expect([isTicked('tickets'), isTicked('queue'), isTicked('plan'), isTicked('browser'), isTicked('scheduler')]).toEqual([true, true, false, false, false]) + expect(dialog.textContent).toContain('queue (changed by hand)') + expect(within(dialog).getByText('Nothing to change.')).toBeTruthy() + expect((within(dialog).getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(box('tickets')) + fireEvent.click(box('browser')) + expect(within(dialog).getByText('1 skill to write, 1 skill to delete.')).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + await waitFor(() => expect(sendChangeSkills).toHaveBeenCalledWith('p1', { write: ['browser'], remove: ['tickets'] })) + expect(await within(dialog).findByText(/Deleted 1 skill/)).toBeTruthy() + expect(dialog.textContent).toContain('Left as it is: .agents/skills/tickets (it holds other files).') + }) + + test('after a save the commit is offered: one press commits the files, and the screen says nothing was pushed', async () => { + onProjectSkills.mockResolvedValue(skills()) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [] }) + sendCommitSkills.mockResolvedValue({ ok: true, committed: true, commit: 'abc1234' }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + expect(await within(dialog).findByText(/not committed\. A commit holds these files alone/)).toBeTruthy() + expect(sendCommitSkills).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: 'Commit these files' })) + await waitFor(() => expect(sendCommitSkills).toHaveBeenCalledWith('p1')) + expect(await within(dialog).findByText(/Committed as abc1234, these files alone\. Nothing was pushed/)).toBeTruthy() + expect(within(dialog).queryByRole('button', { name: 'Commit these files' })).toBeNull() + fireEvent.click(within(dialog).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + test('a save that is refused says why and keeps the list; Cancel writes nothing; a folder that is no repository is offered no commit', async () => { + onProjectSkills.mockResolvedValue(skills({ git: false, startBranch: undefined })) + sendChangeSkills.mockResolvedValueOnce({ ok: false, error: 'EACCES: permission denied' }).mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [], schedulerError: 'hooks.yml: unreadable' }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + let dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + expect(await within(dialog).findByText('EACCES: permission denied')).toBeTruthy() + expect(box('tickets')).toBeTruthy() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(sendChangeSkills).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Add skills' })) + dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + expect(await within(dialog).findByText('This folder is not a git repository, so there is nothing to commit.')).toBeTruthy() + expect(dialog.textContent).toContain('The scheduler could not be switched: hooks.yml: unreadable') + expect(within(dialog).queryByRole('button', { name: 'Commit these files' })).toBeNull() + expect(dialog.textContent).not.toContain('Your agents get these skills') + }) +}) diff --git a/packages/openagent/dashboard/components/ProjectSkills.tsx b/packages/openagent/dashboard/components/ProjectSkills.tsx new file mode 100644 index 000000000..a26b89b42 --- /dev/null +++ b/packages/openagent/dashboard/components/ProjectSkills.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { onProjectSkills, sendChangeSkills, sendCommitSkills, type ProjectSkills, type ProjectSkill } from '../rpc/skills.js' +import { useAction } from '../lib/use-action.js' +import { usePolled } from '../lib/use-async.js' +import { Button } from './ui/button.js' +import { Checkbox } from './ui/checkbox.js' +import { Dialog } from './ui/dialog.js' + +// A project's skills, on its own page (#2023): one line saying how many it has, with "Add skills", +// which opens the same list with ticks `npx @openagt/init` shows in a terminal. Beside it, what a +// person has to know or do about them: a newer text to take, files not committed yet, and the +// skills that are in the folder but not yet on the branch agents start from. + +const held = (skill: ProjectSkill): boolean => skill.standing !== 'absent' +const all = (skills: ProjectSkills): ProjectSkill[] => skills.groups.flatMap(group => group.skills) +const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` + +/** What stands beside a skill's name in the list when its text is not simply the one the dashboard carries. */ +const NOTES: Partial> = { + newer: 'a newer text is there', + changed: 'changed by hand', + own: 'your own text', +} + +export const SCHEDULER_LINE = 'Starts the automations you switch on, while the dashboard is open. This machine only, nothing to commit.' + +export function ProjectSkillsLine({ projectId, onChanged }: { projectId: string; onChanged?: (() => void) | undefined }) { + const { value: skills, reload } = usePolled(() => onProjectSkills(projectId), null, 15_000, [projectId], 'previous') + const [adding, setAdding] = useState(false) + const [committed, setCommitted] = useState(null) + const { busy, error, run } = useAction() + if (!skills) return null + + const changed = async (): Promise => { + await reload() + onChanged?.() + } + const newer = all(skills).filter(skill => skill.standing === 'newer').map(skill => skill.name) + const waiting = all(skills).filter(skill => skill.waiting).map(skill => skill.name) + const update = async (): Promise => { + if ((await run(() => sendChangeSkills(projectId, { write: newer }), 'Could not update the skills.')).ok) await changed() + } + const commit = async (): Promise => { + const outcome = await run(() => sendCommitSkills(projectId), 'Could not commit the skill files.') + if (!outcome.ok) return + setCommitted(outcome.value.commit ?? null) + await changed() + } + + return ( +
+ + This project has {skills.has} of {skills.of} skills. + + + {newer.length > 0 && ( + + {count(newer.length, 'skill has', 'skills have')} a newer text. + + + )} + {skills.uncommitted > 0 && ( + + {count(skills.uncommitted, 'skill file is', 'skill files are')} not committed. + + + )} + {skills.uncommitted === 0 && committed !== null && Committed as {committed}. Nothing was pushed.} + {waiting.length > 0 && skills.startBranch !== undefined && ( + + {waiting.length > 4 ? `${waiting.length} skills` : waiting.join(', ')}: waiting to reach {skills.startBranch}. An agent started now does not have {waiting.length === 1 ? 'it' : 'them'}. + + )} + {error && {error}} + {adding && ( + setAdding(false)} + onSaved={() => void changed()} + /> + )} +
+ ) +} + +/** What is ticked when the list opens: what the project has; in a project with nothing yet, the default picks. */ +function firstTicks(skills: ProjectSkills): { ticked: Set; scheduler: boolean } { + const has = all(skills).filter(held) + if (has.length > 0 || skills.scheduler) return { ticked: new Set(has.map(skill => skill.name)), scheduler: skills.scheduler } + return { ticked: new Set(skills.groups.filter(group => group.ticked).flatMap(group => group.skills.map(skill => skill.name))), scheduler: true } +} + +interface Saved { + written: string[] + removed: string[] + left: { path: string; reason: string }[] + schedulerError?: string | undefined +} + +export function AddSkillsDialog({ projectId, skills, onClose, onSaved }: { projectId: string; skills: ProjectSkills; onClose: () => void; onSaved: () => void }) { + const [first] = useState(() => firstTicks(skills)) + const [ticked, setTicked] = useState(first.ticked) + const [scheduler, setScheduler] = useState(first.scheduler) + const [saved, setSaved] = useState(null) + const [commit, setCommit] = useState<{ commit?: string | undefined; committed: boolean } | null>(null) + const { busy, error, run } = useAction() + + const tick = (name: string, on: boolean): void => + setTicked(current => { + const next = new Set(current) + if (on) next.add(name) + else next.delete(name) + return next + }) + const write = all(skills).filter(skill => ticked.has(skill.name) && !held(skill)).map(skill => skill.name) + const remove = all(skills).filter(skill => !ticked.has(skill.name) && held(skill)).map(skill => skill.name) + const nothing = write.length === 0 && remove.length === 0 && scheduler === skills.scheduler + + const save = async (): Promise => { + const outcome = await run(() => sendChangeSkills(projectId, { write, remove, ...(scheduler !== skills.scheduler ? { scheduler } : {}) }), 'Could not change the skills.') + if (!outcome.ok) return + setSaved(outcome.value) + onSaved() + } + const commitNow = async (): Promise => { + const outcome = await run(() => sendCommitSkills(projectId), 'Could not commit the skill files.') + if (!outcome.ok) return + setCommit(outcome.value) + onSaved() + } + + return ( + { if (!open && !busy) onClose() }} title="Add skills"> + {saved ? ( +
+ {saved.written.length > 0 && ( +

+ Wrote {count(saved.written.length, 'skill', 'skills')} in .agents/skills, linked in{' '} + .claude/skills: {saved.written.join(', ')}. +

+ )} + {saved.removed.length > 0 && ( +

+ Deleted {count(saved.removed.length, 'skill', 'skills')}: {saved.removed.join(', ')}. +

+ )} + {saved.written.length === 0 && saved.removed.length === 0 && !saved.schedulerError &&

Saved.

} + {saved.left.map(({ path, reason }) => ( +

+ Left as it is: {path} ({reason}). +

+ ))} + {saved.schedulerError &&

The scheduler could not be switched: {saved.schedulerError}

} + {saved.written.length + saved.removed.length > 0 && !skills.git &&

This folder is not a git repository, so there is nothing to commit.

} + {saved.written.length + saved.removed.length > 0 && skills.git && commit === null && ( +

The files are in the project’s folder, not committed. A commit holds these files alone, on the branch the folder is on. Nothing is pushed.

+ )} + {commit !== null &&

{commit.committed ? `Committed as ${commit.commit}, these files alone. Nothing was pushed: push it, or open a pull request where the default branch is protected.` : 'Nothing to commit: git already has these files as they are.'}

} + {saved.written.length > 0 && skills.startBranch !== undefined &&

Your agents get these skills once they are on {skills.startBranch}.

} + {error &&

{error}

} +
+ {saved.written.length + saved.removed.length > 0 && skills.git && commit === null && ( + + )} + +
+
+ ) : ( +
+

+ A ticked skill is written into the project’s folder as a file you commit; an unticked one is deleted. Branches, logs, question, and github on a GitHub project come with every run and are not here. +

+
+ {skills.groups.map(group => ( +
+ {group.title} + {group.skills.map(skill => ( + + ))} +
+ ))} +
+ The scheduler + +
+
+ {error &&

{error}

} +
+ + {nothing ? 'Nothing to change.' : [write.length > 0 ? `${count(write.length, 'skill', 'skills')} to write` : '', remove.length > 0 ? `${count(remove.length, 'skill', 'skills')} to delete` : '', scheduler !== skills.scheduler ? `the scheduler ${scheduler ? 'on' : 'off'}` : ''].filter(Boolean).join(', ') + '.'} + + + +
+
+ )} +
+ ) +} diff --git a/packages/openagent/dashboard/lib/use-project-launcher.ts b/packages/openagent/dashboard/lib/use-project-launcher.ts index 0fb4be8e5..c9b82de08 100644 --- a/packages/openagent/dashboard/lib/use-project-launcher.ts +++ b/packages/openagent/dashboard/lib/use-project-launcher.ts @@ -1,10 +1,12 @@ import { onCommands, type ProjectLauncher } from '../rpc/projects.js' -import { useLoaded } from './use-async.js' +import { usePolled } from './use-async.js' /** * What the launcher offers for a project: its commands, and whether it has a start hook. `null` - * until it is read, and for no project: a surface says "no start hook" only once it knows. + * until it is read, and for no project: a surface says "no start hook" only once it knows. Read + * again every few seconds: a skill written into the project's folder, here or in a terminal, is a + * command from then on, and one that reached the branch agents start from stops waiting. */ export function useProjectLauncher(projectId: string | null): ProjectLauncher | null { - return useLoaded(projectId ? () => onCommands(projectId) : null, null, [projectId]) + return usePolled(projectId ? () => onCommands(projectId) : null, null, 15_000, [projectId], 'previous').value } diff --git a/packages/openagent/dashboard/rpc/skills.ts b/packages/openagent/dashboard/rpc/skills.ts new file mode 100644 index 000000000..2c9d31964 --- /dev/null +++ b/packages/openagent/dashboard/rpc/skills.ts @@ -0,0 +1,12 @@ +import type * as impl from '../../src/dashboard-rpc/skills.js' +import { rpc } from '../lib/rpc.js' + +// The types the RPCs speak in, straight from the implementations — erased at build, so importing +// them here pulls no server code into the bundle. +export type * from '../../src/dashboard-rpc/skills.js' + +// Typed stubs for the `skills` RPCs (#2023): what a project has of the skills, the change the +// "Add skills" screen saves, and the one commit of the skill files. +export const onProjectSkills = rpc('onProjectSkills') +export const sendChangeSkills = rpc('sendChangeSkills') +export const sendCommitSkills = rpc('sendCommitSkills') diff --git a/packages/openagent/package.json b/packages/openagent/package.json index 798087789..ebcc8e56c 100644 --- a/packages/openagent/package.json +++ b/packages/openagent/package.json @@ -1,6 +1,6 @@ { "name": "@openagt/dashboard", - "version": "0.9.0", + "version": "0.10.0", "description": "OpenAgent: a local dashboard and daemon for coding agents. It starts an agent through the project's own start hook and shows every agent from the files it keeps; it runs no coding agent itself.", "keywords": [ "ai", @@ -62,6 +62,7 @@ "@base-ui-components/react": "1.0.0-rc.0", "@openagt/agent-data": "workspace:*", "@openagt/files": "workspace:*", + "@openagt/init": "workspace:*", "@openagt/skill-branches": "workspace:*", "@openagt/skill-github": "workspace:*", "@openagt/skill-logs": "workspace:*", diff --git a/packages/openagent/src/dashboard-rpc/index.ts b/packages/openagent/src/dashboard-rpc/index.ts index c0f8a17b6..4ab93df04 100644 --- a/packages/openagent/src/dashboard-rpc/index.ts +++ b/packages/openagent/src/dashboard-rpc/index.ts @@ -21,6 +21,7 @@ export { onQuota } from './quota.js' export { onModels } from './models.js' export { checkDevices, type DeviceCheck } from './devices.js' export { onModules, runModuleCommand, readModule, type DashboardModule, type ModuleCommandResult, type ModuleReadResult } from './modules.js' +export { onProjectSkills, sendChangeSkills, sendCommitSkills, type ProjectSkills, type ProjectSkill, type ChangeSkillsResult } from './skills.js' import * as reads from './reads.js' import * as control from './control.js' @@ -30,6 +31,7 @@ import * as quota from './quota.js' import * as models from './models.js' import * as devices from './devices.js' import * as modules from './modules.js' +import * as skills from './skills.js' import { streamAgentEvents } from './events.js' /** One RPC: called with whatever the browser sent, answering with whatever JSON.stringify keeps. */ @@ -50,7 +52,7 @@ export type RpcHandler = (...args: never[]) => unknown export const RPC_HANDLERS: Record = Object.assign( Object.create(null) as Record, Object.fromEntries( - [reads, control, projects, preferences, quota, models, devices, modules] + [reads, control, projects, preferences, quota, models, devices, modules, skills] .flatMap(module => Object.entries(module)) .filter((entry): entry is [string, RpcHandler] => typeof entry[1] === 'function'), ), diff --git a/packages/openagent/src/dashboard-rpc/projects.ts b/packages/openagent/src/dashboard-rpc/projects.ts index 2da96ce16..9b48a2f94 100644 --- a/packages/openagent/src/dashboard-rpc/projects.ts +++ b/packages/openagent/src/dashboard-rpc/projects.ts @@ -5,6 +5,8 @@ import { readProjectHooks, runCheckHook, startLineTakesBase, type StartReadiness import { isPublishPick, publishPickIn, type PublishPick } from '../publish-levels.js' import { hasRemote } from '../has-remote.js' import { currentBranch } from '../dashboard/git-status.js' +import { startBranch } from '../dashboard/start-branch.js' +import { skillsOn } from '@openagt/init' import { pickDirectory, type PickDirectoryResult } from '../pick-directory.js' import { projectGitHost } from '../store/git-host.js' import type { ProjectSummary } from '../dashboard/projects.js' @@ -130,7 +132,11 @@ export async function onCommands(projectId: string): Promise undefined), hasRemote(cwd), originDefaultBranch(cwd), currentBranch(cwd)]) // Both read locally, never fetched. `HEAD` is a folder on no branch: there is no local branch to start from. const startFrom = hooks.start !== undefined && startLineTakesBase(hooks.start) && remote && main !== undefined && local !== undefined && local !== 'HEAD' ? { main: main.slice('origin/'.length), local } : undefined - return { commands, startHook: hooks.start !== undefined, gitHost: gitHost !== undefined, remote, ...(startFrom ? { startFrom } : {}) } + // A skill written into the folder reaches agents once it is on the branch they start from: until then its command is said to be waiting. + const start = await startBranch(cwd) + const on = start ? await skillsOn(cwd, start.ref) : undefined + const listed = on && start ? commands.map(command => (on.has(command.name) ? command : { ...command, waiting: start.name })) : commands + return { commands: listed, startHook: hooks.start !== undefined, gitHost: gitHost !== undefined, remote, ...(startFrom ? { startFrom } : {}) } } /** diff --git a/packages/openagent/src/dashboard-rpc/skills.test.ts b/packages/openagent/src/dashboard-rpc/skills.test.ts new file mode 100644 index 000000000..da77fc0c9 --- /dev/null +++ b/packages/openagent/src/dashboard-rpc/skills.test.ts @@ -0,0 +1,143 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readState, stopScheduler } from '@openagt/agent-scheduler' +import { addProject, listProjects } from '../registry.js' +import { readProjectModules } from '../project-modules.js' +import { provideTestContext } from './test-context.js' +import { onCommands } from './projects.js' +import { onProjectSkills, sendChangeSkills, sendCommitSkills } from './skills.js' + +// A project's skills through the dashboard's own calls, against a real folder and real git, with +// the registry pointed at a temp $XDG_CONFIG_HOME so the person's own is never touched. +async function project(): Promise<{ dir: string; id: string; git: (...args: string[]) => string; restore: () => Promise }> { + const base = await realpath(await mkdtemp(join(tmpdir(), 'openagent-skills-rpc-'))) + const previous = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(base, 'cfg') + await mkdir(process.env.XDG_CONFIG_HOME, { recursive: true }) + const dir = join(base, 'app') + await mkdir(dir) + const git = (...args: string[]): string => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }) + git('init', '-q', '-b', 'main') + git('config', 'user.email', 'tester@example.com') + git('config', 'user.name', 'tester') + git('config', 'commit.gpgsign', 'false') + await writeFile(join(dir, 'README.md'), '# app\n') + git('add', '-A') + git('commit', '-q', '-m', 'init') + await addProject(dir, new Date().toISOString()) + provideTestContext() + const id = (await listProjects()).find(p => p.path === dir)!.id + return { + dir, + id, + git, + restore: async () => { + if (previous === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = previous + await rm(base, { recursive: true, force: true }) + }, + } +} + +/** Whether a process is still there. */ +const alive = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const flat = (skills: NonNullable>>) => skills.groups.flatMap(group => group.skills) + +test('a project\'s skills: none yet; written, they are in the folder and wait to reach the branch agents start from; committed, they no longer wait', async () => { + const { dir, id, git, restore } = await project() + try { + const empty = (await onProjectSkills(id))! + assert.deepEqual({ has: empty.has, of: empty.of, scheduler: empty.scheduler, startBranch: empty.startBranch, uncommitted: empty.uncommitted, git: empty.git }, { has: 3, of: 25, scheduler: false, startBranch: 'main', uncommitted: 0, git: true }) + assert.deepEqual(empty.groups.map(group => [group.title, group.skills.length]), [['Tickets and queue', 9], ['Reviews and research', 8], ['Subagents', 1], ['After a merge', 1], ['Needs its own setup', 2]]) + assert.ok(flat(empty).every(skill => skill.standing === 'absent' && !skill.waiting && skill.description.length > 10)) + + const written = await sendChangeSkills(id, { write: ['tickets', 'plan'] }) + assert.deepEqual(written, { ok: true, written: ['tickets', 'plan'], removed: [], left: [] }) + assert.match(await readFile(join(dir, '.agents/skills/tickets/SKILL.md'), 'utf8'), /^---\nname: tickets\n/) + const waiting = (await onProjectSkills(id))! + assert.equal(waiting.has, 5) + assert.equal(waiting.uncommitted, 4) + assert.deepEqual(flat(waiting).filter(skill => skill.waiting).map(skill => skill.name), ['tickets', 'plan']) + assert.deepEqual(flat(waiting).filter(skill => skill.standing === 'current').map(skill => skill.name), ['tickets', 'plan']) + // The page follows the text in the folder at once; the launcher says the command has yet to reach the branch. + assert.ok((await readProjectModules(dir)).some(module => module.package === '@openagt/skill-tickets')) + assert.deepEqual((await onCommands(id))!.commands.map(command => [command.name, command.waiting]), [['plan', 'main']]) + + const committed = await sendCommitSkills(id) + assert.ok(committed.ok && committed.committed && /^[0-9a-f]{7,}$/.test(committed.commit!)) + assert.equal(git('log', '--format=%s').trim(), 'Update OpenAgent skills\ninit') + const after = (await onProjectSkills(id))! + assert.equal(after.uncommitted, 0) + assert.deepEqual(flat(after).filter(skill => skill.waiting), []) + assert.deepEqual((await onCommands(id))!.commands.map(command => [command.name, command.waiting]), [['plan', undefined]]) + assert.deepEqual(await sendCommitSkills(id), { ok: true, committed: false }) + + // A tick removed deletes; a text changed since it was written is told as such. + assert.deepEqual(await sendChangeSkills(id, { remove: ['plan'] }), { ok: true, written: [], removed: ['plan'], left: [] }) + await writeFile(join(dir, '.agents/skills/tickets/SKILL.md'), (await readFile(join(dir, '.agents/skills/tickets/SKILL.md'), 'utf8')).replace('# Tickets', '# Our tickets')) + const changed = (await onProjectSkills(id))! + assert.equal(changed.has, 4) + assert.equal(flat(changed).find(skill => skill.name === 'tickets')!.standing, 'changed') + assert.equal(flat(changed).find(skill => skill.name === 'plan')!.standing, 'absent') + } finally { + await restore() + } +}) + +test('with a remote the branch agents start from is the remote\'s: a skill committed here still waits until it is there', async () => { + const { dir, id, git, restore } = await project() + try { + git('init', '-q', '--bare', join(dir, '..', 'origin.git')) + git('remote', 'add', 'origin', join(dir, '..', 'origin.git')) + git('push', '-q', '-u', 'origin', 'main') + await sendChangeSkills(id, { write: ['queue'] }) + await sendCommitSkills(id) + const local = (await onProjectSkills(id))! + assert.equal(local.startBranch, 'main') + assert.deepEqual(flat(local).filter(skill => skill.waiting).map(skill => skill.name), ['queue'], 'committed on this machine, not on origin\'s main yet') + git('push', '-q', 'origin', 'main') + assert.deepEqual(flat((await onProjectSkills(id))!).filter(skill => skill.waiting), []) + } finally { + await restore() + } +}) + +test('the scheduler is switched on and off for the project, and only names of the list are taken', async () => { + const { dir, id, restore } = await project() + try { + assert.deepEqual(await sendChangeSkills(id, { scheduler: true }), { ok: true, written: [], removed: [], left: [] }) + assert.equal((await onProjectSkills(id))!.scheduler, true) + assert.match(await readFile(join(dir, '.openagent/hooks.yml'), 'utf8'), /agent-scheduler start/) + // The dashboard is open, so the scheduler runs from this moment, not from the next opening. + const running = await readState(dir) + assert.ok(running.on && running.pid !== undefined && alive(running.pid), 'its process is ticking') + await sendChangeSkills(id, { scheduler: false }) + assert.equal((await onProjectSkills(id))!.scheduler, false) + assert.equal((await readState(dir)).on, false, 'and switched off, it is stopped') + for (let waited = 0; alive(running.pid!) && waited < 5000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50)) + assert.equal(alive(running.pid!), false, 'its process ended') + + for (const change of [{ write: ['../../etc'] }, { write: ['scheduler'] }, { remove: ['logs'] }, { write: 'tickets' }, { scheduler: 'yes' }] as never[]) { + assert.deepEqual(await sendChangeSkills(id, change), { ok: false, error: 'not a list of skills' }, JSON.stringify(change)) + } + assert.deepEqual((await onProjectSkills(id))!.has, 3, 'nothing of a refused change was written') + assert.equal(await onProjectSkills('no-such-project'), null) + assert.deepEqual(await sendChangeSkills('no-such-project', { write: ['tickets'] }), { ok: false, error: 'this project has no local path on this server' }) + assert.deepEqual(await sendCommitSkills('no-such-project'), { ok: false, error: 'this project has no local path on this server' }) + } finally { + await stopScheduler(dir).catch(() => {}) + await restore() + } +}) diff --git a/packages/openagent/src/dashboard-rpc/skills.ts b/packages/openagent/src/dashboard-rpc/skills.ts new file mode 100644 index 000000000..1f930a677 --- /dev/null +++ b/packages/openagent/src/dashboard-rpc/skills.ts @@ -0,0 +1,102 @@ +import { applyChange, carriedSkills, commitSkills, GROUPS, isKnown, readProject, SCHEDULER, skillCount, SKILLS_IN_ALL, skillsOn, uncommittedSkills, type Standing } from '@openagt/init' +import { startScheduler } from '@openagt/agent-scheduler' +import { startBranch } from '../dashboard/start-branch.js' +import { providedDataChanged } from '../store/provided.js' +import { resolveProjectPath } from './context.js' + +/** + * A project's skills, as the dashboard shows and changes them (#2023): the same list with ticks + * `npx @openagt/init` shows in a terminal, read and written through that package's own code, so + * the two cannot drift apart. A project has a skill when the skill's text is in its folder; the + * dashboard writes and deletes those files, and offers the one commit that holds them. + */ + +/** One skill of the list, as it stands in the project. */ +export interface ProjectSkill { + name: string + /** The one line the skill's text says of itself. */ + description: string + /** How the project's text stands against the one this dashboard carries; `absent` when the project does not hold the skill. */ + standing: Standing + /** Whether the skill is in the folder but not yet on the branch agents start from: an agent started now does not have it. */ + waiting: boolean +} + +export interface ProjectSkills { + /** How many skills the project has, of how many there are: the ones in its folder, and the basic ones every run gets. */ + has: number + of: number + /** The list's groups; `ticked` says whether a group's skills are ticked in a project that has none yet. */ + groups: { title: string; ticked: boolean; skills: ProjectSkill[] }[] + /** Whether the scheduler starts with the dashboard for this project, on this machine. */ + scheduler: boolean + /** The branch agents start from, by its name: the remote's default branch, else the branch the folder is on. Absent where there is none. */ + startBranch?: string + /** How many skill files stand written or deleted in the folder with no commit yet. */ + uncommitted: number + /** Whether the folder is a git repository: without one there is nothing to commit. */ + git: boolean +} + +/** The project's skills; `null` when the project is unknown here. */ +export async function onProjectSkills(projectId: string): Promise { + const cwd = await resolveProjectPath(projectId) + if (!cwd) return null + const [state, carried, start] = await Promise.all([readProject(cwd), carriedSkills(), startBranch(cwd)]) + const on = start ? await skillsOn(cwd, start.ref) : new Set() + const standing = new Map(state.skills.map(skill => [skill.name, skill.standing])) + return { + has: skillCount(state), + of: SKILLS_IN_ALL, + groups: GROUPS.map(group => ({ + title: group.title, + ticked: group.ticked, + skills: group.skills.map(name => { + const stands = standing.get(name) ?? 'absent' + return { name, description: carried.get(name)!.description, standing: stands, waiting: stands !== 'absent' && start !== undefined && !on.has(name) } + }), + })), + scheduler: state.scheduler, + ...(start ? { startBranch: start.name } : {}), + uncommitted: state.git ? await uncommittedSkills(cwd) : 0, + git: state.git, + } +} + +/** What a change of skills answered: what was written and deleted, and what was left as it is. */ +export type ChangeSkillsResult = + | { ok: true; written: string[]; removed: string[]; left: { path: string; reason: string }[]; schedulerError?: string } + | { ok: false; error: string } + +const names = (value: unknown): string[] | undefined => (value === undefined ? [] : Array.isArray(value) && value.every(name => typeof name === 'string' && name !== SCHEDULER && isKnown(name)) ? [...new Set(value as string[])] : undefined) + +/** + * Write and delete skills in a project's folder, and switch its scheduler: what the "Add skills" + * screen saves. Only the names of the list are taken; anything else is refused whole. The scheduler + * switched on starts at once, as its line would have at this dashboard's opening. Nothing is + * committed here: `sendCommitSkills` is the person's next press. + */ +export async function sendChangeSkills(projectId: string, change: { write?: string[]; remove?: string[]; scheduler?: boolean }): Promise { + const cwd = await resolveProjectPath(projectId) + if (!cwd) return { ok: false, error: 'this project has no local path on this server' } + const write = names(change?.write) + const remove = names(change?.remove) + if (!write || !remove || (change.scheduler !== undefined && typeof change.scheduler !== 'boolean')) return { ok: false, error: 'not a list of skills' } + try { + const changed = await applyChange(cwd, { write, remove, ...(change.scheduler !== undefined ? { scheduler: change.scheduler } : {}) }) + providedDataChanged(cwd) + // The scheduler's line runs when the dashboard opens, and this dashboard is open: switched on here, it starts now. + const started = changed.scheduler && !('error' in changed.scheduler) && changed.scheduler.on && changed.scheduler.changed ? await startScheduler(cwd).then(() => undefined, (err: unknown) => (err instanceof Error ? err.message : String(err))) : undefined + const schedulerError = changed.scheduler && 'error' in changed.scheduler ? changed.scheduler.error : started + return { ok: true, written: changed.written, removed: changed.removed, left: changed.left, ...(schedulerError !== undefined ? { schedulerError } : {}) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +/** One commit of the skill files that stand uncommitted in the project's folder, those alone, on the branch the folder is on. Never a push. */ +export async function sendCommitSkills(projectId: string): Promise<{ ok: true; committed: boolean; commit?: string } | { ok: false; error: string }> { + const cwd = await resolveProjectPath(projectId) + if (!cwd) return { ok: false, error: 'this project has no local path on this server' } + return commitSkills(cwd) +} diff --git a/packages/openagent/src/dashboard/start-branch.ts b/packages/openagent/src/dashboard/start-branch.ts new file mode 100644 index 000000000..3392ef794 --- /dev/null +++ b/packages/openagent/src/dashboard/start-branch.ts @@ -0,0 +1,15 @@ +import { originDefaultBranch } from '@openagt/agent-data' +import { currentBranch } from './git-status.js' + +/** + * The branch agents start from in the project at `cwd`: origin's default branch as this clone has + * it, read locally and never fetched, else the branch the folder is on (a project with no remote + * starts an agent from its last local commit). `ref` is what git is asked, `name` what a person + * reads. None where the folder is on no branch, or is no repository. + */ +export async function startBranch(cwd: string): Promise<{ ref: string; name: string } | undefined> { + const main = await originDefaultBranch(cwd).catch(() => undefined) + if (main !== undefined) return { ref: main, name: main.slice('origin/'.length) } + const local = await currentBranch(cwd).catch(() => undefined) + return local !== undefined && local !== 'HEAD' ? { ref: 'HEAD', name: local } : undefined +} diff --git a/packages/openagent/src/project-commands.ts b/packages/openagent/src/project-commands.ts index be2d65a7f..de670e9df 100644 --- a/packages/openagent/src/project-commands.ts +++ b/packages/openagent/src/project-commands.ts @@ -19,6 +19,8 @@ export interface ProjectCommand { name: string /** The `description` of the skill's front matter, when it has one. */ description?: string + /** The branch the skill has yet to reach, by name, when its text is in the folder but not on the branch agents start from: an agent started now does not have the command. */ + waiting?: string } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304a3b732..7614fe041 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -366,6 +366,9 @@ importers: '@openagt/files': specifier: workspace:* version: link:../files + '@openagt/init': + specifier: workspace:* + version: link:../init '@openagt/skill-branches': specifier: workspace:* version: link:../skill-branches From 7b5164da7c78b5f6ea43ac632b60f587216380c6 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 10 Oct 2026 07:14:51 +0300 Subject: [PATCH 2/2] After the review: waiting only when known, the launcher takes no waiting command, the commit by name, the scheduler started by its own line --- packages/init/src/branch.test.ts | 63 ++++++++++++--- packages/init/src/branch.ts | 81 ++++++++++++++----- packages/init/src/index.ts | 2 +- packages/openagent/DECISIONS.md | 12 +-- .../dashboard/components/Composer.tsx | 4 +- .../components/ProjectSkills.test.tsx | 50 ++++++++++-- .../dashboard/components/ProjectSkills.tsx | 33 +++++--- .../dashboard/components/StartAgentForm.tsx | 7 +- .../dashboard/lib/use-project-launcher.ts | 14 +++- .../openagent/src/dashboard-rpc/projects.ts | 11 +-- .../src/dashboard-rpc/skills.test.ts | 41 ++++++++-- .../openagent/src/dashboard-rpc/skills.ts | 71 ++++++++++------ .../openagent/src/dashboard/start-branch.ts | 47 ++++++++++- packages/openagent/src/project-commands.ts | 4 +- packages/openagent/src/project-hooks.ts | 3 + 15 files changed, 342 insertions(+), 101 deletions(-) diff --git a/packages/init/src/branch.test.ts b/packages/init/src/branch.test.ts index ff261d594..33b08a4c7 100644 --- a/packages/init/src/branch.test.ts +++ b/packages/init/src/branch.test.ts @@ -1,35 +1,72 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { commitSkills, skillsOn, uncommittedSkills } from './branch.js' import { applyChange, commitChange } from './change.js' import { folder, gone, run } from './test-project.js' -test('a skill written into the folder is not on the branch until it is committed; then it is, by its text and by its link', async () => { +/** Which of `names` are on `ref`. */ +async function on(root: string, ref: string, ...names: string[]): Promise { + const branch = await skillsOn(root, ref) + return names.filter(name => branch.has(name)) +} + +test('a skill written into the folder is not on the branch until it is committed; a project\'s own skill counts by its text or by its link', async () => { const root = await folder(true) try { - assert.deepEqual([...(await skillsOn(root, 'HEAD'))], []) + assert.deepEqual(await on(root, 'HEAD', 'tickets', 'queue'), []) const changed = await applyChange(root, { write: ['tickets', 'queue'] }) - assert.deepEqual([...(await skillsOn(root, 'HEAD'))], [], 'written, not committed') + assert.deepEqual(await on(root, 'HEAD', 'tickets', 'queue'), [], 'written, not committed') assert.equal(await uncommittedSkills(root), 4, 'two texts and two links') await commitChange(root, changed) - assert.deepEqual([...(await skillsOn(root, 'HEAD'))].sort(), ['queue', 'tickets']) + assert.deepEqual(await on(root, 'HEAD', 'tickets', 'queue', 'plan'), ['tickets', 'queue']) assert.equal(await uncommittedSkills(root), 0) - // A project's own skill, held as a real folder where Claude Code reads, and one outside init's list. + // The project's own skills: a real folder where Claude Code reads, and a link to a folder kept somewhere else in the project. await mkdir(join(root, '.claude/skills/deploy'), { recursive: true }) await writeFile(join(root, '.claude/skills/deploy/SKILL.md'), '---\nname: deploy\n---\nOurs.\n') + await mkdir(join(root, 'our-skills/release'), { recursive: true }) + await writeFile(join(root, 'our-skills/release/SKILL.md'), '---\nname: release\n---\nOurs.\n') + await symlink('../../our-skills/release', join(root, '.claude/skills/release')) + assert.deepEqual(await on(root, 'HEAD', 'deploy', 'release'), []) await run(root, 'add', '-A') await run(root, 'commit', '-q', '-m', 'ours') - assert.deepEqual([...(await skillsOn(root, 'HEAD'))].sort(), ['deploy', 'queue', 'tickets']) - assert.deepEqual([...(await skillsOn(root, 'no-such-branch'))], [], 'a branch that is not there has none') + assert.deepEqual(await on(root, 'HEAD', 'deploy', 'release', 'tickets', 'plan'), ['deploy', 'release', 'tickets']) + assert.deepEqual(await on(root, 'no-such-branch', 'tickets'), [], 'a branch that is not there has none') } finally { await gone(root) } }) -test('the dashboard\'s commit takes every skill file that stands uncommitted, a deleted one too, and nothing else', async () => { +test('where the skills folder is itself a link: to the other skills folder, the texts are read there; to anywhere else, nothing is said to be missing', async () => { + const root = await folder(true) + try { + await mkdir(join(root, '.agents/skills/tickets'), { recursive: true }) + await writeFile(join(root, '.agents/skills/tickets/SKILL.md'), '---\nname: tickets\n---\n') + await mkdir(join(root, '.claude')) + await symlink('../.agents/skills', join(root, '.claude/skills')) + await run(root, 'add', '-A') + await run(root, 'commit', '-q', '-m', 'linked folder') + assert.deepEqual(await on(root, 'HEAD', 'tickets', 'queue'), ['tickets']) + // The dashboard's commit works in this shape: the text is one path, and no path goes through the link. + await applyChange(root, { write: ['queue'] }) + assert.equal(await uncommittedSkills(root), 1) + const committed = await commitSkills(root) + assert.ok(committed.ok && committed.committed, JSON.stringify(committed)) + assert.equal(await run(root, 'show', '--format=', '--name-only', 'HEAD'), '.agents/skills/queue/SKILL.md\n') + + await rm(join(root, '.claude/skills')) + await symlink('../shared/skills', join(root, '.claude/skills')) + await run(root, 'add', '-A') + await run(root, 'commit', '-q', '-m', 'linked elsewhere') + assert.deepEqual(await on(root, 'HEAD', 'tickets', 'anything'), ['tickets', 'anything'], 'not known, so nothing is held back') + } finally { + await gone(root) + } +}) + +test('the commit of every skill file that stands uncommitted takes a deleted one too, and nothing else; with names it takes those skills\' files alone', async () => { const root = await folder(true) try { await commitChange(root, await applyChange(root, { write: ['tickets', 'ux'] })) @@ -40,6 +77,14 @@ test('the dashboard\'s commit takes every skill file that stands uncommitted, a await run(root, 'add', 'staged.txt') assert.equal(await uncommittedSkills(root), 5, 'plan\'s text and link, ux\'s text and link, tickets\' text') + // By name: what a change wrote or deleted, and not the text somebody changed by hand meanwhile. + const named = await commitSkills(root, ['plan']) + assert.ok(named.ok && named.committed) + assert.equal(await run(root, 'show', '--format=%s', '--name-status', 'HEAD'), 'Update OpenAgent skills\n\nA\t.agents/skills/plan/SKILL.md\nA\t.claude/skills/plan\n') + assert.equal(await uncommittedSkills(root), 3) + await run(root, 'reset', '-q', '--soft', 'HEAD~1') + await run(root, 'reset', '-q', '--', '.agents/skills/plan', '.claude/skills/plan') + const committed = await commitSkills(root) assert.ok(committed.ok && committed.committed) assert.equal(await run(root, 'show', '--format=%s', '--name-status', 'HEAD'), 'Update OpenAgent skills\n\nA\t.agents/skills/plan/SKILL.md\nM\t.agents/skills/tickets/SKILL.md\nD\t.agents/skills/ux/SKILL.md\nA\t.claude/skills/plan\nD\t.claude/skills/ux\n') diff --git a/packages/init/src/branch.ts b/packages/init/src/branch.ts index 22cd38cef..067a3f0c1 100644 --- a/packages/init/src/branch.ts +++ b/packages/init/src/branch.ts @@ -1,5 +1,5 @@ import { lstat } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { nodeGitRunner, type GitRunner } from '@openagt/agent-data' import { SKILL_NAMES } from './catalogue.js' import { commitPaths, type CommitOutcome } from './commit.js' @@ -12,40 +12,79 @@ import { LINKS_DIR, TEXTS_DIR } from './project.js' * says it is waiting, and offers the commit. */ -/** The names of the skills whose text is on `ref`, in either folder: the text itself, or a link there to a text that is. */ -export async function skillsOn(root: string, ref: string, git: GitRunner = nodeGitRunner()): Promise> { - const listed = await git(['ls-tree', '-r', '-z', '--name-only', ref, '--', TEXTS_DIR, LINKS_DIR], root).catch(() => '') - const paths = new Set(listed.split('\0').filter(Boolean)) +/** Which skills are on a branch: asked by name. */ +export interface OnBranch { + has(name: string): boolean +} + +/** Everything counts as there: the answer where the branch cannot be read, so that nothing is held back on a guess. */ +const UNKNOWN: OnBranch = { has: () => true } + +/** + * The skills on `ref`, in either folder: a text there, or a link named as the skill (where it + * leads is the project's own business). Read with one listing of the two folders. Where the + * listing fails, or a skills folder is itself a link to somewhere the listing does not show, the + * answer is that every skill is there: a skill is said to be waiting only when that is known. + */ +export async function skillsOn(root: string, ref: string, git: GitRunner = nodeGitRunner()): Promise { + // No commit behind the ref: a repository that has none yet, a branch that is not there. Nothing is on it. + if (!(await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root).then(() => true, () => false))) return { has: () => false } + const listed = await git(['ls-tree', '-r', '-z', ref, '--', TEXTS_DIR, LINKS_DIR], root).catch(() => undefined) + if (listed === undefined) return UNKNOWN const on = new Set() - const texts = (dir: string): string[] => [...paths].flatMap(path => (path.startsWith(`${dir}/`) && path.endsWith('/SKILL.md') && path.split('/').length === 4 ? [path.split('/')[2]!] : [])) - for (const name of [...texts(TEXTS_DIR), ...texts(LINKS_DIR)]) on.add(name) - // A link is one entry of the tree, named as the skill: it counts where the text it leads to is on the branch too. - for (const path of paths) { - const [, , name, more] = path.split('/') - if (path.startsWith(`${LINKS_DIR}/`) && name !== undefined && more === undefined && paths.has(`${TEXTS_DIR}/${name}/SKILL.md`)) on.add(name) + let linkedFolders = 0 + for (const entry of listed.split('\0').filter(Boolean)) { + const [meta, path] = entry.split('\t') as [string, string] + const link = meta.startsWith('120000 ') + const [first, second, name, file, more] = path.split('/') + const dir = `${first}/${second}` + if (dir !== TEXTS_DIR && dir !== LINKS_DIR) continue + if (name === undefined) linkedFolders += link ? 1 : 0 + else if (file === undefined ? link : file === 'SKILL.md' && more === undefined) on.add(name) } + // Claude Code's folder linked to the other one is the usual shape, and that one is listed. Any other linked folder is not. + if (linkedFolders > 0 && !(linkedFolders === 1 && (await linksTo(root, ref, LINKS_DIR, TEXTS_DIR, git)))) return UNKNOWN return on } -/** The paths git may hold for the skills of `init`'s list, from the project's root: each text, each link, and a text in a folder of Claude Code's own where there is no link. */ -async function skillPaths(root: string): Promise { +/** Whether `ref` holds `from` as a link that leads to the folder `to`. */ +async function linksTo(root: string, ref: string, from: string, to: string, git: GitRunner): Promise { + const target = await git(['cat-file', 'blob', `${ref}:${from}`], root).catch(() => undefined) + return target !== undefined && join(dirname(from), target.trim()).replace(/\/$/, '') === to +} + +/** Whether a path from the project's root goes through a link on the way: git takes a link as one path, and refuses one that goes through it. */ +async function throughLink(root: string, path: string): Promise { + for (let dir = dirname(path); dir !== '.'; dir = dirname(dir)) { + if (await lstat(join(root, dir)).then(entry => entry.isSymbolicLink(), () => false)) return true + } + return false +} + +/** The paths git may hold for `names`, from the project's root: each text, each link, and a text in a folder of Claude Code's own where there is no link. */ +async function skillPaths(root: string, names: readonly string[]): Promise { const paths: string[] = [] - for (const name of SKILL_NAMES) { - paths.push(`${TEXTS_DIR}/${name}/SKILL.md`) - // Git takes a link as one path, and refuses a path that goes through one. + for (const name of names) { const link = await lstat(join(root, LINKS_DIR, name)).catch(() => undefined) - paths.push(link?.isDirectory() ? `${LINKS_DIR}/${name}/SKILL.md` : `${LINKS_DIR}/${name}`) + for (const path of [`${TEXTS_DIR}/${name}/SKILL.md`, link?.isDirectory() ? `${LINKS_DIR}/${name}/SKILL.md` : `${LINKS_DIR}/${name}`]) { + if (!(await throughLink(root, path))) paths.push(path) + } } return paths } /** How many skill files stand written, changed or deleted in the folder with no commit yet. */ export async function uncommittedSkills(root: string, git: GitRunner = nodeGitRunner()): Promise { - const status = await git(['status', '--porcelain', '-z', '-uall', '--', ...(await skillPaths(root))], root).catch(() => '') + // Read without taking git's lock on the index: this is polled, and must never stand in the way of the person's own git. + const status = await git(['--no-optional-locks', 'status', '--porcelain', '-z', '-uall', '--', ...(await skillPaths(root, SKILL_NAMES))], root).catch(() => '') return status.split('\0').filter(Boolean).length } -/** One commit of every skill file that stands uncommitted, and nothing else: what a dashboard's "Commit" does. Never a push. */ -export async function commitSkills(root: string, git: GitRunner = nodeGitRunner()): Promise { - return commitPaths(root, await skillPaths(root), 'Update OpenAgent skills\n', git) +/** + * One commit of skill files that stand uncommitted, and nothing else. With `names`, the files of + * those skills alone: what a dashboard commits after a change, the same commit `init` offers in a + * terminal. Without, every uncommitted file of the list's skills. Never a push. + */ +export async function commitSkills(root: string, names: readonly string[] = SKILL_NAMES, git: GitRunner = nodeGitRunner()): Promise { + return commitPaths(root, await skillPaths(root, names), 'Update OpenAgent skills\n', git) } diff --git a/packages/init/src/index.ts b/packages/init/src/index.ts index d72998de9..8d168e7fb 100644 --- a/packages/init/src/index.ts +++ b/packages/init/src/index.ts @@ -3,4 +3,4 @@ export { readProject, held, skillCount, standingOf, TEXTS_DIR, LINKS_DIR, type P export { applyChange, commitChange, commitMessage, isKnown, type Change, type Changed } from './change.js' export { type CommitOutcome } from './commit.js' export { stamped, unstamped, olderThan } from './skill-file.js' -export { skillsOn, uncommittedSkills, commitSkills } from './branch.js' +export { skillsOn, uncommittedSkills, commitSkills, type OnBranch } from './branch.js' diff --git a/packages/openagent/DECISIONS.md b/packages/openagent/DECISIONS.md index ca3eec895..a838b0adc 100644 --- a/packages/openagent/DECISIONS.md +++ b/packages/openagent/DECISIONS.md @@ -113,11 +113,13 @@ already made, and lists it in its pull request; for anything else it proposes an skills have a newer text") with an "Update" button; the texts are written only on that press, and the person reads the change in git. Picked over saying nothing until asked, and over writing every text again unasked. -- The dashboard offers the same commit init does: one commit of the skill files that - stand uncommitted, those alone, on the branch the folder is on, never a push. While a - skill's text is in the folder but not yet on the branch agents start from, the page - says so ("tickets: waiting to reach main"), and the launcher shows its command as - waiting and does not take it, so it never offers a command the agent does not have. +- After a change the dashboard offers the same commit init does: one commit of the skill + files that change wrote or deleted, those alone, on the branch the folder is on, never + a push. While a skill's text is in the folder but not yet on the branch agents start + from, the page says so ("tickets: waiting to reach main"), and the launcher does not + take its command, so it never offers a command the agent does not have. Picked over + leaving the commit to the person with a line of advice, and over committing and + pushing unasked. - A module may bring a section of the Settings page, shown after OpenAgent's own sections, where a project has its package. It reads and writes through the package's own command. Picked over OpenAgent's own sections knowing a package's file and a hook line diff --git a/packages/openagent/dashboard/components/Composer.tsx b/packages/openagent/dashboard/components/Composer.tsx index 940cc61b2..4bd87dc26 100644 --- a/packages/openagent/dashboard/components/Composer.tsx +++ b/packages/openagent/dashboard/components/Composer.tsx @@ -22,7 +22,7 @@ import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-tar import { useDeviceStatus } from '../lib/use-device-status.js' import { stashDraftFromUrl, takePendingDraft } from '../lib/draft-handoff.js' import { driverOptions, useModels } from '../lib/models.js' -import { useProjectLauncher } from '../lib/use-project-launcher.js' +import { readyCommands, useProjectLauncher } from '../lib/use-project-launcher.js' import { ClaudeLogo, CodexLogo } from './driver-logos.js' import { Button } from './ui/button.js' import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js' @@ -192,7 +192,7 @@ export const Composer = forwardRef = {}): ProjectSkills { startBranch: 'main', uncommitted: 0, git: true, + remote: true, ...over, } } @@ -96,21 +97,21 @@ describe('ProjectSkillsLine', () => { onProjectSkills.mockResolvedValue(skills({ has: 5, groups: [{ title: 'Tickets and queue', ticked: true, skills: [skill('tickets', 'current', true), skill('queue', 'current', true), skill('plan', 'current')] }] })) const { container } = render() await screen.findByText(/This project has 5 of 25 skills\./) - expect(container.textContent).toContain('tickets, queue: waiting to reach main. An agent started now does not have them.') + expect(container.textContent).toContain('tickets, queue: waiting to reach main. An agent started from main does not have them.') }) test('many waiting skills are counted, not named', async () => { onProjectSkills.mockResolvedValue(skills({ has: 8, groups: [{ title: 'Tickets and queue', ticked: true, skills: ['tickets', 'queue', 'plan', 'triage', 'ux'].map(name => skill(name, 'current', true)) }] })) const { container } = render() await screen.findByText(/This project has 8 of 25 skills\./) - expect(container.textContent).toContain('5 skills: waiting to reach main. An agent started now does not have them.') + expect(container.textContent).toContain('5 skills: waiting to reach main. An agent started from main does not have them.') }) }) describe('Add skills', () => { test('in a project with no skills yet the default picks are ticked: every group but the one that needs a setup of its own, and the scheduler', async () => { onProjectSkills.mockResolvedValue(skills()) - sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [] }) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [], scheduler: 'on' }) const onChanged = vi.fn() render() fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) @@ -125,6 +126,7 @@ describe('Add skills', () => { expect(await within(dialog).findByText(/Wrote 2 skills in/)).toBeTruthy() expect(dialog.textContent).toContain('tickets, queue') expect(dialog.textContent).toContain('Your agents get these skills once they are on main.') + expect(dialog.textContent).toContain('The scheduler is on for this project, on this machine. Every automation starts switched off.') await waitFor(() => expect(onChanged).toHaveBeenCalled()) }) @@ -161,7 +163,7 @@ describe('Add skills', () => { expect(await within(dialog).findByText(/not committed\. A commit holds these files alone/)).toBeTruthy() expect(sendCommitSkills).not.toHaveBeenCalled() fireEvent.click(within(dialog).getByRole('button', { name: 'Commit these files' })) - await waitFor(() => expect(sendCommitSkills).toHaveBeenCalledWith('p1')) + await waitFor(() => expect(sendCommitSkills).toHaveBeenCalledWith('p1', ['tickets', 'queue'])) expect(await within(dialog).findByText(/Committed as abc1234, these files alone\. Nothing was pushed/)).toBeTruthy() expect(within(dialog).queryByRole('button', { name: 'Commit these files' })).toBeNull() fireEvent.click(within(dialog).getByRole('button', { name: 'Done' })) @@ -169,7 +171,9 @@ describe('Add skills', () => { }) test('a save that is refused says why and keeps the list; Cancel writes nothing; a folder that is no repository is offered no commit', async () => { - onProjectSkills.mockResolvedValue(skills({ git: false, startBranch: undefined })) + // A folder that is no repository: nothing to commit, and no branch agents start from. + const { startBranch: _none, ...noRepository } = skills({ git: false }) + onProjectSkills.mockResolvedValue(noRepository) sendChangeSkills.mockResolvedValueOnce({ ok: false, error: 'EACCES: permission denied' }).mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [], schedulerError: 'hooks.yml: unreadable' }) render() fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) @@ -185,8 +189,42 @@ describe('Add skills', () => { dialog = await screen.findByRole('dialog') fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) expect(await within(dialog).findByText('This folder is not a git repository, so there is nothing to commit.')).toBeTruthy() - expect(dialog.textContent).toContain('The scheduler could not be switched: hooks.yml: unreadable') + expect(dialog.textContent).toContain('The scheduler was not switched: hooks.yml: unreadable') expect(within(dialog).queryByRole('button', { name: 'Commit these files' })).toBeNull() expect(dialog.textContent).not.toContain('Your agents get these skills') }) }) + +describe('the skills line, over time', () => { + test('the list a person has open keeps the project as it stood when it opened, whatever a later read says', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + onProjectSkills.mockResolvedValueOnce(skills()).mockResolvedValue(skills({ has: 4, groups: [{ title: 'Tickets and queue', ticked: true, skills: [skill('tickets', 'current'), skill('queue')] }] })) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('2 skills to write, the scheduler on.')).toBeTruthy() + // The next read lands while the list is open: the project now holds tickets (written in a terminal, say). + await vi.advanceTimersByTimeAsync(16_000) + await waitFor(() => expect(onProjectSkills.mock.calls.length).toBeGreaterThanOrEqual(2)) + expect(within(dialog).getByText('2 skills to write, the scheduler on.')).toBeTruthy() + expect(within(dialog).queryByText(/to delete/)).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + test('in a project with no remote a commit is all agents need, and the screen says so', async () => { + onProjectSkills.mockResolvedValue(skills({ remote: false })) + sendChangeSkills.mockResolvedValue({ ok: true, written: ['tickets', 'queue'], removed: [], left: [] }) + sendCommitSkills.mockResolvedValue({ ok: true, committed: true, commit: 'abc1234' }) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Add skills' })) + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Save' })) + fireEvent.click(await within(dialog).findByRole('button', { name: 'Commit these files' })) + expect(await within(dialog).findByText('Committed as abc1234, these files alone. Your agents have these skills from their next start.')).toBeTruthy() + expect(dialog.textContent).not.toContain('push it') + expect(dialog.textContent).not.toContain('once they are on main') + }) +}) diff --git a/packages/openagent/dashboard/components/ProjectSkills.tsx b/packages/openagent/dashboard/components/ProjectSkills.tsx index a26b89b42..c55f95a3d 100644 --- a/packages/openagent/dashboard/components/ProjectSkills.tsx +++ b/packages/openagent/dashboard/components/ProjectSkills.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { onProjectSkills, sendChangeSkills, sendCommitSkills, type ProjectSkills, type ProjectSkill } from '../rpc/skills.js' import { useAction } from '../lib/use-action.js' import { usePolled } from '../lib/use-async.js' @@ -26,9 +26,15 @@ export const SCHEDULER_LINE = 'Starts the automations you switch on, while the d export function ProjectSkillsLine({ projectId, onChanged }: { projectId: string; onChanged?: (() => void) | undefined }) { const { value: skills, reload } = usePolled(() => onProjectSkills(projectId), null, 15_000, [projectId], 'previous') - const [adding, setAdding] = useState(false) + // The list opens on the project as it stands at that moment: a later read must not change it under the person's ticks. + const [adding, setAdding] = useState(null) const [committed, setCommitted] = useState(null) const { busy, error, run } = useAction() + // The note of a commit made here holds until skill files stand uncommitted again. + const uncommitted = skills?.uncommitted ?? 0 + useEffect(() => { + if (uncommitted > 0) setCommitted(null) + }, [uncommitted]) if (!skills) return null const changed = async (): Promise => { @@ -51,7 +57,7 @@ export function ProjectSkillsLine({ projectId, onChanged }: { projectId: string;
This project has {skills.has} of {skills.of} skills. - @@ -74,15 +80,15 @@ export function ProjectSkillsLine({ projectId, onChanged }: { projectId: string; {skills.uncommitted === 0 && committed !== null && Committed as {committed}. Nothing was pushed.} {waiting.length > 0 && skills.startBranch !== undefined && ( - {waiting.length > 4 ? `${waiting.length} skills` : waiting.join(', ')}: waiting to reach {skills.startBranch}. An agent started now does not have {waiting.length === 1 ? 'it' : 'them'}. + {waiting.length > 4 ? `${waiting.length} skills` : waiting.join(', ')}: waiting to reach {skills.startBranch}. An agent started from {skills.startBranch} does not have {waiting.length === 1 ? 'it' : 'them'}. )} {error && {error}} {adding && ( setAdding(false)} + skills={adding} + onClose={() => setAdding(null)} onSaved={() => void changed()} /> )} @@ -101,6 +107,7 @@ interface Saved { written: string[] removed: string[] left: { path: string; reason: string }[] + scheduler?: 'on' | 'off' | undefined schedulerError?: string | undefined } @@ -130,7 +137,8 @@ export function AddSkillsDialog({ projectId, skills, onClose, onSaved }: { proje onSaved() } const commitNow = async (): Promise => { - const outcome = await run(() => sendCommitSkills(projectId), 'Could not commit the skill files.') + // The files this save wrote or deleted, and no other skill file that stands uncommitted. + const outcome = await run(() => sendCommitSkills(projectId, [...(saved?.written ?? []), ...(saved?.removed ?? [])]), 'Could not commit the skill files.') if (!outcome.ok) return setCommit(outcome.value) onSaved() @@ -151,19 +159,22 @@ export function AddSkillsDialog({ projectId, skills, onClose, onSaved }: { proje Deleted {count(saved.removed.length, 'skill', 'skills')}: {saved.removed.join(', ')}.

)} - {saved.written.length === 0 && saved.removed.length === 0 && !saved.schedulerError &&

Saved.

} + {saved.scheduler === 'on' &&

The scheduler is on for this project, on this machine. Every automation starts switched off.

} + {saved.scheduler === 'off' &&

The scheduler is off for this project on this machine, and stopped.

} {saved.left.map(({ path, reason }) => (

Left as it is: {path} ({reason}).

))} - {saved.schedulerError &&

The scheduler could not be switched: {saved.schedulerError}

} + {saved.schedulerError &&

The scheduler was not switched: {saved.schedulerError}

} {saved.written.length + saved.removed.length > 0 && !skills.git &&

This folder is not a git repository, so there is nothing to commit.

} {saved.written.length + saved.removed.length > 0 && skills.git && commit === null && (

The files are in the project’s folder, not committed. A commit holds these files alone, on the branch the folder is on. Nothing is pushed.

)} - {commit !== null &&

{commit.committed ? `Committed as ${commit.commit}, these files alone. Nothing was pushed: push it, or open a pull request where the default branch is protected.` : 'Nothing to commit: git already has these files as they are.'}

} - {saved.written.length > 0 && skills.startBranch !== undefined &&

Your agents get these skills once they are on {skills.startBranch}.

} + {commit !== null && !commit.committed &&

Nothing to commit: git already has these files as they are.

} + {commit?.committed && skills.remote &&

Committed as {commit.commit}, these files alone. Nothing was pushed: push it, or open a pull request where the default branch is protected.

} + {commit?.committed && !skills.remote &&

Committed as {commit.commit}, these files alone.{saved.written.length > 0 ? ' Your agents have these skills from their next start.' : ''}

} + {saved.written.length > 0 && skills.startBranch !== undefined && !(commit?.committed && !skills.remote) &&

Your agents get these skills once they are on {skills.startBranch}.

} {error &&

{error}

}
{saved.written.length + saved.removed.length > 0 && skills.git && commit === null && ( diff --git a/packages/openagent/dashboard/components/StartAgentForm.tsx b/packages/openagent/dashboard/components/StartAgentForm.tsx index 74c496ca1..a73657a61 100644 --- a/packages/openagent/dashboard/components/StartAgentForm.tsx +++ b/packages/openagent/dashboard/components/StartAgentForm.tsx @@ -7,7 +7,7 @@ import { offeredPublishPicks, publishPickIn } from '../../src/client.js' import { useConnectionProfiles } from '../lib/profiles.js' import { useSelectedRemoteDeviceId } from '../lib/remote-target.js' import { cleanupPick, offersPostMergeCleanup, startPicks, useStartAgent } from '../lib/use-start-agent.js' -import { useProjectLauncher } from '../lib/use-project-launcher.js' +import { readyCommands, useProjectLauncher } from '../lib/use-project-launcher.js' import { useLoaded } from '../lib/use-async.js' import { promptWithContext } from '../lib/use-context-set.js' import { AutoMenu } from './AutoMenu.js' @@ -86,8 +86,9 @@ export function StartAgentForm({ const selectedDeviceId = useSelectedRemoteDeviceId() const remoteDevice = selectedDeviceId ? profiles.find(p => p.id === selectedDeviceId) : undefined const noStartHook = launcher !== null && !launcher.startHook && !remoteDevice - // A device starts the run in its own project, whose commands this launcher does not read. - const commands = remoteDevice ? [] : (launcher?.commands ?? []) + // A device starts the run in its own project, whose commands this launcher does not read. A command + // whose skill has yet to reach the branch the agent starts from is not one the run can follow with. + const commands = remoteDevice ? [] : readyCommands(launcher?.commands ?? []) const offersCleanup = offersPostMergeCleanup(commands) // Whether a pull request can be opened here: unknown until the launcher is read, and a device's // own project is not read at all, so both are offered every pick; the daemon that starts the run diff --git a/packages/openagent/dashboard/lib/use-project-launcher.ts b/packages/openagent/dashboard/lib/use-project-launcher.ts index c9b82de08..4b8212dda 100644 --- a/packages/openagent/dashboard/lib/use-project-launcher.ts +++ b/packages/openagent/dashboard/lib/use-project-launcher.ts @@ -1,12 +1,24 @@ import { onCommands, type ProjectLauncher } from '../rpc/projects.js' import { usePolled } from './use-async.js' +import { usePreferences } from './preferences.js' /** * What the launcher offers for a project: its commands, and whether it has a start hook. `null` * until it is read, and for no project: a surface says "no start hook" only once it knows. Read * again every few seconds: a skill written into the project's folder, here or in a terminal, is a * command from then on, and one that reached the branch agents start from stops waiting. + * + * A command is waiting for the branch the person starts agents from: where they picked the + * folder's own branch as the start, a skill that branch already has is not waiting. */ export function useProjectLauncher(projectId: string | null): ProjectLauncher | null { - return usePolled(projectId ? () => onCommands(projectId) : null, null, 15_000, [projectId], 'previous').value + const launcher = usePolled(projectId ? () => onCommands(projectId) : null, null, 15_000, [projectId]).value + const fromHere = usePreferences().startFrom?.[projectId ?? ''] === 'local' + if (!launcher || !fromHere || !launcher.startFrom) return launcher + return { ...launcher, commands: launcher.commands.map(({ waiting, here, ...command }) => (here ? command : { ...command, ...(waiting !== undefined ? { waiting } : {}) })) } +} + +/** The commands an agent started now has: the ones not waiting to reach the branch it starts from. */ +export function readyCommands(commands: readonly T[]): T[] { + return commands.filter(command => command.waiting === undefined) } diff --git a/packages/openagent/src/dashboard-rpc/projects.ts b/packages/openagent/src/dashboard-rpc/projects.ts index 9b48a2f94..9a30edfe2 100644 --- a/packages/openagent/src/dashboard-rpc/projects.ts +++ b/packages/openagent/src/dashboard-rpc/projects.ts @@ -5,8 +5,7 @@ import { readProjectHooks, runCheckHook, startLineTakesBase, type StartReadiness import { isPublishPick, publishPickIn, type PublishPick } from '../publish-levels.js' import { hasRemote } from '../has-remote.js' import { currentBranch } from '../dashboard/git-status.js' -import { startBranch } from '../dashboard/start-branch.js' -import { skillsOn } from '@openagt/init' +import { waitingSkills } from '../dashboard/start-branch.js' import { pickDirectory, type PickDirectoryResult } from '../pick-directory.js' import { projectGitHost } from '../store/git-host.js' import type { ProjectSummary } from '../dashboard/projects.js' @@ -133,9 +132,11 @@ export async function onCommands(projectId: string): Promise (on.has(command.name) ? command : { ...command, waiting: start.name })) : commands + const { waiting } = await waitingSkills(cwd, commands.map(command => command.name)) + const listed = commands.map(command => { + const waits = waiting.get(command.name) + return waits ? { ...command, waiting: waits.branch, ...(waits.here ? { here: true as const } : {}) } : command + }) return { commands: listed, startHook: hooks.start !== undefined, gitHost: gitHost !== undefined, remote, ...(startFrom ? { startFrom } : {}) } } diff --git a/packages/openagent/src/dashboard-rpc/skills.test.ts b/packages/openagent/src/dashboard-rpc/skills.test.ts index da77fc0c9..baa83f62a 100644 --- a/packages/openagent/src/dashboard-rpc/skills.test.ts +++ b/packages/openagent/src/dashboard-rpc/skills.test.ts @@ -60,6 +60,7 @@ test('a project\'s skills: none yet; written, they are in the folder and wait to try { const empty = (await onProjectSkills(id))! assert.deepEqual({ has: empty.has, of: empty.of, scheduler: empty.scheduler, startBranch: empty.startBranch, uncommitted: empty.uncommitted, git: empty.git }, { has: 3, of: 25, scheduler: false, startBranch: 'main', uncommitted: 0, git: true }) + assert.equal(empty.remote, false) assert.deepEqual(empty.groups.map(group => [group.title, group.skills.length]), [['Tickets and queue', 9], ['Reviews and research', 8], ['Subagents', 1], ['After a merge', 1], ['Needs its own setup', 2]]) assert.ok(flat(empty).every(skill => skill.standing === 'absent' && !skill.waiting && skill.description.length > 10)) @@ -75,9 +76,15 @@ test('a project\'s skills: none yet; written, they are in the folder and wait to assert.ok((await readProjectModules(dir)).some(module => module.package === '@openagt/skill-tickets')) assert.deepEqual((await onCommands(id))!.commands.map(command => [command.name, command.waiting]), [['plan', 'main']]) + // By name, as after a save: the files of that skill alone. Then everything that still stands uncommitted. + const one = await sendCommitSkills(id, ['plan']) + assert.ok(one.ok && one.committed && /^[0-9a-f]{7,}$/.test(one.commit!)) + assert.equal(git('show', '--format=', '--name-only', 'HEAD'), '.agents/skills/plan/SKILL.md\n.claude/skills/plan\n') + assert.equal((await onProjectSkills(id))!.uncommitted, 2) + assert.deepEqual(await sendCommitSkills(id, ['../x']), { ok: false, error: 'not a list of skills' }) const committed = await sendCommitSkills(id) - assert.ok(committed.ok && committed.committed && /^[0-9a-f]{7,}$/.test(committed.commit!)) - assert.equal(git('log', '--format=%s').trim(), 'Update OpenAgent skills\ninit') + assert.ok(committed.ok && committed.committed) + assert.equal(git('log', '--format=%s').trim(), 'Update OpenAgent skills\nUpdate OpenAgent skills\ninit') const after = (await onProjectSkills(id))! assert.equal(after.uncommitted, 0) assert.deepEqual(flat(after).filter(skill => skill.waiting), []) @@ -114,22 +121,44 @@ test('with a remote the branch agents start from is the remote\'s: a skill commi } }) +test('a skill that reached the remote\'s branch elsewhere, a pull request merged on the git host, stops waiting here: this clone\'s copy of the branch is fetched first', async () => { + const { dir, id, git, restore } = await project() + try { + git('init', '-q', '--bare', join(dir, '..', 'origin.git')) + git('remote', 'add', 'origin', join(dir, '..', 'origin.git')) + git('push', '-q', '-u', 'origin', 'main') + const before = git('rev-parse', 'origin/main').trim() + await writeFile(join(dir, '.git', 'x'), '') + // The skill is committed and on the remote's main, and this clone's copy of that branch does not know yet. + const { applyChange, commitChange } = await import('@openagt/init') + await commitChange(dir, await applyChange(dir, { write: ['queue'] })) + git('push', '-q', 'origin', 'main') + git('update-ref', 'refs/remotes/origin/main', before) + assert.equal(git('rev-parse', 'origin/main').trim(), before) + assert.deepEqual(flat((await onProjectSkills(id))!).filter(skill => skill.waiting), [], 'fetched, and found there') + assert.notEqual(git('rev-parse', 'origin/main').trim(), before) + } finally { + await restore() + } +}) + test('the scheduler is switched on and off for the project, and only names of the list are taken', async () => { const { dir, id, restore } = await project() try { - assert.deepEqual(await sendChangeSkills(id, { scheduler: true }), { ok: true, written: [], removed: [], left: [] }) + assert.deepEqual(await sendChangeSkills(id, { scheduler: true }), { ok: true, written: [], removed: [], left: [], scheduler: 'on' }) assert.equal((await onProjectSkills(id))!.scheduler, true) assert.match(await readFile(join(dir, '.openagent/hooks.yml'), 'utf8'), /agent-scheduler start/) - // The dashboard is open, so the scheduler runs from this moment, not from the next opening. + // The dashboard is open, so the scheduler runs from this moment, not from the next opening: its own line was run. + assert.deepEqual(await sendChangeSkills(id, { scheduler: true }), { ok: true, written: [], removed: [], left: [] }, 'asked again: nothing to switch') const running = await readState(dir) assert.ok(running.on && running.pid !== undefined && alive(running.pid), 'its process is ticking') - await sendChangeSkills(id, { scheduler: false }) + assert.deepEqual(await sendChangeSkills(id, { scheduler: false }), { ok: true, written: [], removed: [], left: [], scheduler: 'off' }) assert.equal((await onProjectSkills(id))!.scheduler, false) assert.equal((await readState(dir)).on, false, 'and switched off, it is stopped') for (let waited = 0; alive(running.pid!) && waited < 5000; waited += 50) await new Promise(resolve => setTimeout(resolve, 50)) assert.equal(alive(running.pid!), false, 'its process ended') - for (const change of [{ write: ['../../etc'] }, { write: ['scheduler'] }, { remove: ['logs'] }, { write: 'tickets' }, { scheduler: 'yes' }] as never[]) { + for (const change of [{ write: ['../../etc'] }, { write: ['scheduler'] }, { remove: ['logs'] }, { write: 'tickets' }, { scheduler: 'yes' }, null, 7, ['tickets']] as never[]) { assert.deepEqual(await sendChangeSkills(id, change), { ok: false, error: 'not a list of skills' }, JSON.stringify(change)) } assert.deepEqual((await onProjectSkills(id))!.has, 3, 'nothing of a refused change was written') diff --git a/packages/openagent/src/dashboard-rpc/skills.ts b/packages/openagent/src/dashboard-rpc/skills.ts index 1f930a677..67ab435b0 100644 --- a/packages/openagent/src/dashboard-rpc/skills.ts +++ b/packages/openagent/src/dashboard-rpc/skills.ts @@ -1,6 +1,6 @@ -import { applyChange, carriedSkills, commitSkills, GROUPS, isKnown, readProject, SCHEDULER, skillCount, SKILLS_IN_ALL, skillsOn, uncommittedSkills, type Standing } from '@openagt/init' -import { startScheduler } from '@openagt/agent-scheduler' -import { startBranch } from '../dashboard/start-branch.js' +import { applyChange, carriedSkills, commitSkills, GROUPS, isKnown, readProject, SCHEDULER, skillCount, SKILLS_IN_ALL, uncommittedSkills, type Standing } from '@openagt/init' +import { waitingSkills } from '../dashboard/start-branch.js' +import { readProjectHooks, runProjectHooks } from '../project-hooks.js' import { providedDataChanged } from '../store/provided.js' import { resolveProjectPath } from './context.js' @@ -18,7 +18,7 @@ export interface ProjectSkill { description: string /** How the project's text stands against the one this dashboard carries; `absent` when the project does not hold the skill. */ standing: Standing - /** Whether the skill is in the folder but not yet on the branch agents start from: an agent started now does not have it. */ + /** Whether the skill is in the folder but not yet on the branch agents start from: an agent started from it does not have the skill. */ waiting: boolean } @@ -36,67 +36,86 @@ export interface ProjectSkills { uncommitted: number /** Whether the folder is a git repository: without one there is nothing to commit. */ git: boolean + /** Whether the project has a remote: without one a commit on this machine is all agents need. */ + remote: boolean } /** The project's skills; `null` when the project is unknown here. */ export async function onProjectSkills(projectId: string): Promise { const cwd = await resolveProjectPath(projectId) if (!cwd) return null - const [state, carried, start] = await Promise.all([readProject(cwd), carriedSkills(), startBranch(cwd)]) - const on = start ? await skillsOn(cwd, start.ref) : new Set() + const [state, carried] = await Promise.all([readProject(cwd), carriedSkills()]) const standing = new Map(state.skills.map(skill => [skill.name, skill.standing])) + const { start, waiting } = await waitingSkills(cwd, state.skills.filter(skill => skill.standing !== 'absent').map(skill => skill.name)) return { has: skillCount(state), of: SKILLS_IN_ALL, groups: GROUPS.map(group => ({ title: group.title, ticked: group.ticked, - skills: group.skills.map(name => { - const stands = standing.get(name) ?? 'absent' - return { name, description: carried.get(name)!.description, standing: stands, waiting: stands !== 'absent' && start !== undefined && !on.has(name) } - }), + skills: group.skills.map(name => ({ name, description: carried.get(name)!.description, standing: standing.get(name) ?? 'absent', waiting: waiting.has(name) })), })), scheduler: state.scheduler, ...(start ? { startBranch: start.name } : {}), uncommitted: state.git ? await uncommittedSkills(cwd) : 0, git: state.git, + remote: state.remote, } } -/** What a change of skills answered: what was written and deleted, and what was left as it is. */ +/** What a change of skills answered: what was written and deleted, what was left as it is, and the scheduler when the change switched it. */ export type ChangeSkillsResult = - | { ok: true; written: string[]; removed: string[]; left: { path: string; reason: string }[]; schedulerError?: string } + | { ok: true; written: string[]; removed: string[]; left: { path: string; reason: string }[]; scheduler?: 'on' | 'off'; schedulerError?: string } | { ok: false; error: string } +/** A list of the list's own skill names, each once; nothing for anything else. */ const names = (value: unknown): string[] | undefined => (value === undefined ? [] : Array.isArray(value) && value.every(name => typeof name === 'string' && name !== SCHEDULER && isKnown(name)) ? [...new Set(value as string[])] : undefined) /** * Write and delete skills in a project's folder, and switch its scheduler: what the "Add skills" - * screen saves. Only the names of the list are taken; anything else is refused whole. The scheduler - * switched on starts at once, as its line would have at this dashboard's opening. Nothing is - * committed here: `sendCommitSkills` is the person's next press. + * screen saves. Only the names of the list are taken; anything else is refused whole. A scheduler + * switched on starts at once, by its own line, as it would have at this dashboard's opening. + * Nothing is committed here: `sendCommitSkills` is the person's next press. */ export async function sendChangeSkills(projectId: string, change: { write?: string[]; remove?: string[]; scheduler?: boolean }): Promise { const cwd = await resolveProjectPath(projectId) if (!cwd) return { ok: false, error: 'this project has no local path on this server' } - const write = names(change?.write) - const remove = names(change?.remove) - if (!write || !remove || (change.scheduler !== undefined && typeof change.scheduler !== 'boolean')) return { ok: false, error: 'not a list of skills' } + const asked = change && typeof change === 'object' && !Array.isArray(change) ? change : undefined + const write = names(asked?.write) + const remove = names(asked?.remove) + if (!asked || !write || !remove || (asked.scheduler !== undefined && typeof asked.scheduler !== 'boolean')) return { ok: false, error: 'not a list of skills' } try { - const changed = await applyChange(cwd, { write, remove, ...(change.scheduler !== undefined ? { scheduler: change.scheduler } : {}) }) + const opened = (await readProjectHooks(cwd).catch(() => undefined))?.open ?? [] + const changed = await applyChange(cwd, { write, remove, ...(asked.scheduler !== undefined ? { scheduler: asked.scheduler } : {}) }) providedDataChanged(cwd) - // The scheduler's line runs when the dashboard opens, and this dashboard is open: switched on here, it starts now. - const started = changed.scheduler && !('error' in changed.scheduler) && changed.scheduler.on && changed.scheduler.changed ? await startScheduler(cwd).then(() => undefined, (err: unknown) => (err instanceof Error ? err.message : String(err))) : undefined - const schedulerError = changed.scheduler && 'error' in changed.scheduler ? changed.scheduler.error : started - return { ok: true, written: changed.written, removed: changed.removed, left: changed.left, ...(schedulerError !== undefined ? { schedulerError } : {}) } + const scheduler = changed.scheduler + // The lines the change added run when the dashboard opens, and this dashboard is open: they run now, the way they would have. + if (scheduler && !('error' in scheduler) && scheduler.on && scheduler.changed) { + const added = ((await readProjectHooks(cwd).catch(() => undefined))?.open ?? []).filter(line => !opened.includes(line)) + if (added.length > 0) await runProjectHooks(cwd, 'open', { only: added, log: console.log }) + } + return { + ok: true, + written: changed.written, + removed: changed.removed, + left: changed.left, + ...(scheduler && !('error' in scheduler) && scheduler.changed ? { scheduler: scheduler.on ? ('on' as const) : ('off' as const) } : {}), + ...(scheduler && 'error' in scheduler ? { schedulerError: scheduler.error } : {}), + } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } } -/** One commit of the skill files that stand uncommitted in the project's folder, those alone, on the branch the folder is on. Never a push. */ -export async function sendCommitSkills(projectId: string): Promise<{ ok: true; committed: boolean; commit?: string } | { ok: false; error: string }> { +/** + * One commit of skill files that stand uncommitted in the project's folder, on the branch the + * folder is on: the files of the skills `only` names, after a change that wrote or deleted them, + * else every uncommitted skill file. Never a push. + */ +export async function sendCommitSkills(projectId: string, only?: string[]): Promise<{ ok: true; committed: boolean; commit?: string } | { ok: false; error: string }> { const cwd = await resolveProjectPath(projectId) if (!cwd) return { ok: false, error: 'this project has no local path on this server' } - return commitSkills(cwd) + const named = names(only) + if (!named) return { ok: false, error: 'not a list of skills' } + return named.length > 0 ? commitSkills(cwd, named) : commitSkills(cwd) } diff --git a/packages/openagent/src/dashboard/start-branch.ts b/packages/openagent/src/dashboard/start-branch.ts index 3392ef794..082f1f01f 100644 --- a/packages/openagent/src/dashboard/start-branch.ts +++ b/packages/openagent/src/dashboard/start-branch.ts @@ -1,11 +1,12 @@ -import { originDefaultBranch } from '@openagt/agent-data' +import { nodeGitRunner, originDefaultBranch } from '@openagt/agent-data' +import { skillsOn } from '@openagt/init' import { currentBranch } from './git-status.js' /** * The branch agents start from in the project at `cwd`: origin's default branch as this clone has - * it, read locally and never fetched, else the branch the folder is on (a project with no remote - * starts an agent from its last local commit). `ref` is what git is asked, `name` what a person - * reads. None where the folder is on no branch, or is no repository. + * it, else the branch the folder is on (a project with no remote starts an agent from its last + * local commit). `ref` is what git is asked, `name` what a person reads. None where the folder is + * on no branch, or is no repository. */ export async function startBranch(cwd: string): Promise<{ ref: string; name: string } | undefined> { const main = await originDefaultBranch(cwd).catch(() => undefined) @@ -13,3 +14,41 @@ export async function startBranch(cwd: string): Promise<{ ref: string; name: str const local = await currentBranch(cwd).catch(() => undefined) return local !== undefined && local !== 'HEAD' ? { ref: 'HEAD', name: local } : undefined } + +/** A skill that is in the folder and not on the branch agents start from. */ +export interface Waiting { + /** The branch it has yet to reach, by name. */ + branch: string + /** Whether the branch the folder is on has it: an agent started from that branch does. */ + here: boolean +} + +/** How long this clone's copy of the remote's default branch counts as fresh, per project. */ +const FRESH_MS = 60_000 +/** How long a read waits for the fetch that freshens it; the fetch itself goes on. */ +const FETCH_WAIT_MS = 5_000 +const fetched = new Map() + +/** + * Which of the skills `names`, all in the project's folder, are waiting to reach the branch agents + * start from. A skill is said to be waiting only when that is known: an agent's checkout fetches + * the remote before it starts, so before a skill is called waiting against a remote branch, this + * clone's copy of that branch is fetched too (at most once a minute per project), or a pull + * request merged a moment ago would keep its skill waiting here. + */ +export async function waitingSkills(cwd: string, names: readonly string[]): Promise<{ start?: { ref: string; name: string }; waiting: Map }> { + const start = await startBranch(cwd) + if (!start || names.length === 0) return { ...(start ? { start } : {}), waiting: new Map() } + let on = await skillsOn(cwd, start.ref) + let missing = names.filter(name => !on.has(name)) + if (missing.length > 0 && start.ref !== 'HEAD' && Date.now() - (fetched.get(cwd) ?? 0) > FRESH_MS) { + fetched.set(cwd, Date.now()) + const fetch = nodeGitRunner()(['fetch', '--quiet', 'origin', start.name], cwd).catch(() => undefined) + await Promise.race([fetch, new Promise(resolve => setTimeout(resolve, FETCH_WAIT_MS))]) + on = await skillsOn(cwd, start.ref) + missing = names.filter(name => !on.has(name)) + } + if (missing.length === 0) return { start, waiting: new Map() } + const here = start.ref === 'HEAD' ? { has: () => false } : await skillsOn(cwd, 'HEAD') + return { start, waiting: new Map(missing.map(name => [name, { branch: start.name, here: here.has(name) }])) } +} diff --git a/packages/openagent/src/project-commands.ts b/packages/openagent/src/project-commands.ts index de670e9df..a22a62446 100644 --- a/packages/openagent/src/project-commands.ts +++ b/packages/openagent/src/project-commands.ts @@ -19,8 +19,10 @@ export interface ProjectCommand { name: string /** The `description` of the skill's front matter, when it has one. */ description?: string - /** The branch the skill has yet to reach, by name, when its text is in the folder but not on the branch agents start from: an agent started now does not have the command. */ + /** The branch the skill has yet to reach, by name, when its text is in the folder but not on the branch agents start from: an agent started from that branch does not have the command. */ waiting?: string + /** With `waiting`: whether the branch the folder is on has the skill, so an agent started from that branch does have the command. */ + here?: true } /** diff --git a/packages/openagent/src/project-hooks.ts b/packages/openagent/src/project-hooks.ts index d64e93e0f..bc3e364e6 100644 --- a/packages/openagent/src/project-hooks.ts +++ b/packages/openagent/src/project-hooks.ts @@ -110,6 +110,8 @@ export interface RunHooksOptions { timeoutMs?: number /** The environment the lines run with; default the daemon's own. */ env?: NodeJS.ProcessEnv + /** The lines to run, of the ones the file holds; every line when absent. */ + only?: readonly string[] } /** @@ -122,6 +124,7 @@ export async function runProjectHooks(cwd: string, kind: HookKind, opts: RunHook const log = opts.log ?? (() => {}) const hooks = await readProjectHooks(cwd, message => log(`[openagent] ${kind} hook (${basename(cwd)}): ${message}`)) for (const line of hooks[kind]) { + if (opts.only && !opts.only.includes(line)) continue const outcome = await runLine(cwd, line, opts.timeoutMs ?? HOOK_TIMEOUT_MS, opts.env ?? process.env) log(`[openagent] ${kind} hook (${basename(cwd)}): ${line}: ${outcome.summary}`) for (const said of outcome.stderr.split('\n').map(s => s.trimEnd()).filter(Boolean)) log(`[openagent] ${said}`)