Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent-data/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-driver/claude/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-driver/codex/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-driver/core/package.json
Original file line number Diff line number Diff line change
@@ -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-<name>.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-driver/github-actions/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-runner/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/files/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
98 changes: 98 additions & 0 deletions packages/init/src/branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
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'

/** Which of `names` are on `ref`. */
async function on(root: string, ref: string, ...names: string[]): Promise<string[]> {
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 on(root, 'HEAD', 'tickets', 'queue'), [])
const changed = await applyChange(root, { write: ['tickets', 'queue'] })
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 on(root, 'HEAD', 'tickets', 'queue', 'plan'), ['tickets', 'queue'])
assert.equal(await uncommittedSkills(root), 0)

// 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 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('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'] }))
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')

// 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')
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)
}
})
90 changes: 90 additions & 0 deletions packages/init/src/branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { lstat } from 'node:fs/promises'
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'
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.
*/

/** 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<OnBranch> {
// 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<string>()
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
}

/** 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<boolean> {
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<boolean> {
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<string[]> {
const paths: string[] = []
for (const name of names) {
const link = await lstat(join(root, LINKS_DIR, name)).catch(() => undefined)
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<number> {
// 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 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<CommitOutcome> {
return commitPaths(root, await skillPaths(root, names), 'Update OpenAgent skills\n', git)
}
1 change: 1 addition & 0 deletions packages/init/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, type OnBranch } from './branch.js'
17 changes: 17 additions & 0 deletions packages/openagent/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ 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.
- 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
Expand Down
2 changes: 2 additions & 0 deletions packages/openagent/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
/>
)
}
Expand Down
9 changes: 9 additions & 0 deletions packages/openagent/dashboard/components/CommandsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions packages/openagent/dashboard/components/CommandsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,13 @@ export function CommandsMenu({
<DropdownMenuLabel>Commands</DropdownMenuLabel>
{commands.length === 0 && <div className="px-2 py-1.5 text-xs text-muted-foreground">This project has no commands.</div>}
{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 = <OptionLabel label={`/${c.name}`} />
const label = <OptionLabel label={`/${c.name}`} {...(c.waiting !== undefined ? { description: `waiting to reach ${c.waiting}` } : {})} />
if (!c.description)
return (
<DropdownMenuItem key={c.name} {...itemProps}>
Expand Down
4 changes: 2 additions & 2 deletions packages/openagent/dashboard/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -192,7 +192,7 @@ export const Composer = forwardRef<ComposerHandle, {
{...(removeContext ? { onMentionRemoved: removeContext } : {})}
projects={projects}
files={files}
commands={commands}
commands={readyCommands(commands)}
customPresets={customPresets}
projectPresets={projectPresets}
// The `/` menu offers "Save prompt…" only in the full composer, where the create panel renders;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div>actions</div> }))
vi.mock('./ProjectErrorBanner.js', () => ({ ProjectErrorBanner: () => null }))
vi.mock('./ProjectSkills.js', () => ({ ProjectSkillsLine: () => <div>skills line</div> }))
vi.mock('./StartAgentForm.js', () => ({ StartAgentForm: () => <div>start form</div> }))
vi.mock('./AgentOverview.js', () => ({ AgentOverview: () => <div>overview</div> }))
vi.mock('./OpenQuestions.js', () => ({ OpenQuestions: () => <div>open questions</div> }))
Expand Down
Loading
Loading