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
8 changes: 8 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ architecture lives in [docs/architecture.md](docs/architecture.md) and
Current stage and priorities: [ROADMAP.md](ROADMAP.md). How decisions are made:
[docs/governance.md](docs/governance.md). Dated decisions: [decisions/](decisions/README.md).

## Independent personal-agent foundation — 2026-11-02

#2170 introduces user-owned identities, per-Project participation/default routing,
and migration that retains private carrier IDs, notes, conversations and Sessions.
Leaving/retiring revokes only that agent's grants and preserves private history.
External descriptors remain unverified; authentication/transport and actual provider
acceptance are #2183/#2185, compatibility #2186. This is source work, not live rollout.

## Backend convergence — 2026-11-02

Project convergence #2171 now adds explicit repository associations and non-destructive Owner double-confirmed closure on the delivery branch. It preserves unfinished Task records and memory; runtime source migration and new behavior are not deployed. Chief role proposals now require separate Maintainer/Owner approval with exact-hash/revision fencing and durable Effects proof; retirement preserves Task/Session and room history. Custom-role physical execution remains #2184 acceptance, and later convergence issues remain active.
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# FlyBot — Roadmap

## Independent personal-agent foundation — 2026-11-02

#2170 introduces user-owned identities, per-Project participation/default routing,
and migration that retains private carrier IDs, notes, conversations and Sessions.
Leaving/retiring revokes only that agent's grants and preserves private history.
External descriptors remain unverified; authentication/transport and actual provider
acceptance are #2183/#2185, compatibility #2186. This is source work, not live rollout.

## Backend convergence — 2026-11-02

Implement the 2026-11-02 convergence issues sequentially: Project boundaries, independent personal agents/authorization, execution and collaboration, context, Task/subtask lifecycle, SQLite/state, unified API/events, compatibility, then #2189 real deployment/README. Each issue has a separate main-repository branch/PR; no automatic merge or production rollout. #2171 includes repository relations, Owner closure and Chief-proposed/Owner-or-Maintainer-approved role governance. Execution integration for custom/retired roles remains in #2184; ancestor changes must propagate through the PR stack before final compatibility acceptance.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2170-personal-agents.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add independent user-owned personal-agent identities and isolated Project participations, retaining legacy private histories and scoped delegation revocation.
1 change: 1 addition & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss
// Task channel names. Requester checks live on the record in tasks.js; listing them
// here keeps a future `task-*` from auto-allowing for any signed-in actor (#111).
const TASK_ENDPOINTS = new Set([
'personal-agent-history', 'personal-agent-create', 'personal-agent-read', 'personal-agent-list', 'personal-agent-join', 'personal-agent-default', 'personal-agent-participations', 'personal-agent-leave', 'personal-agent-retire',
'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-send-back', 'task-cancel',
'task-wait-input', 'task-answer-input',
// Saving a workflow definition writes `workflows/<id>.json` in the workspace (#1272 step 3): the
Expand Down
11 changes: 10 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export function install(rt) {
// What a host that only reads the team may still answer.
// Updates change the dsh profile, not the team, so a reading host may run them too.
// auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write.
const READS = new Set(['project-roles', 'project-role-proposals', 'project-repositories', 'project-rooms', 'project-room-read', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows'])
const READS = new Set(['project-roles', 'project-role-proposals', 'personal-agent-history', 'personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'project-repositories', 'project-rooms', 'project-room-read', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows'])

// A chat reference chip names a Session whose snapshot rides to every recipient of
// the message, so the author must be able to read what they share (#53, #106).
Expand Down Expand Up @@ -822,6 +822,15 @@ export function install(rt) {
case 'project-terminal-close':
case 'project-execution-input':
case 'project-resource-environment-check':
case 'personal-agent-history':
case 'personal-agent-create':
case 'personal-agent-read':
case 'personal-agent-list':
case 'personal-agent-join':
case 'personal-agent-default':
case 'personal-agent-participations':
case 'personal-agent-leave':
case 'personal-agent-retire':
case 'projects':
case 'project-repositories':
case 'project-repository-link':
Expand Down
186 changes: 186 additions & 0 deletions core/src/host/personal-agents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// User-owned identities are independent of their Project conversation carriers.
import { createHash, randomUUID } from 'node:crypto'
import { ownEntry, setEntry } from './store.js'

const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 32)
const failure = (message, code = 'bot/forbidden') => Object.assign(new Error(message), { code })
const endpoints = new Set(['personal-agent-create', 'personal-agent-read', 'personal-agent-list', 'personal-agent-join', 'personal-agent-default', 'personal-agent-participations', 'personal-agent-leave', 'personal-agent-retire', 'personal-agent-history'])
const legacyId = (projectId, ownerId) => `private-${hash(`${projectId}\n${ownerId}`)}`
const slot = (projectId, ownerId) => `${projectId}\n${ownerId}`
const bounded = (value, name, max = 120) => {
if (typeof value !== 'string' || !value.trim() || Array.from(value).length > max) throw failure(`Invalid ${name}`, 'bot/invalid')
return value.trim()
}

export function install(rt) {
const { state } = rt
const agents = () => state.personalAgents ?? {}
const participations = () => state.personalAgentParticipations ?? {}
const defaults = () => state.personalAgentDefaults ?? {}
function tables() {
for (const key of ['personalAgents', 'personalAgentParticipations', 'personalAgentDefaults']) state[key] ??= {}
}
const agentOf = id => ownEntry(agents(), id)
function owned(id, ownerId) {
const agent = agentOf(String(id ?? ''))
if (!agent || agent.ownerId !== ownerId) throw failure('Personal agent access denied: only its owner may use it')
return agent
}
function defaultAgent(ownerId) {
tables()
const id = `personal-agent-${hash(ownerId)}`
let agent = agentOf(id)
if (!agent) {
agent = { id, ownerId, name: 'Personal agent', kind: 'internal', status: 'active', createdAt: Date.now(), memoryScopeId: id }
setEntry(state.personalAgents, id, agent)
}
if (agent.status !== 'active') throw failure('Default personal agent is retired; select an active agent')
return agent
}
function defaultParticipation(projectId, ownerId) {
const row = ownEntry(participations(), ownEntry(defaults(), slot(projectId, ownerId)))
return row?.status === 'active' && agentOf(row.agentId)?.status === 'active' ? row : null
}
function join(agent, projectId, { carrierId } = {}) {
if (agent.status !== 'active') throw failure('Personal agent is retired')
tables()
const id = `participation-${hash(`${agent.id}\n${projectId}`)}`
let row = ownEntry(state.personalAgentParticipations, id)
if (row) { row.status = 'active'; return row }
const old = legacyId(projectId, agent.ownerId)
const oldTaken = Object.values(participations()).some(item => item.carrierId === old)
row = { id, agentId: agent.id, ownerId: agent.ownerId, projectId, status: 'active', createdAt: Date.now(),
carrierId: carrierId ?? (!oldTaken ? old : `private-${hash(id)}`), contextScopeId: id }
setEntry(state.personalAgentParticipations, id, row)
if (!ownEntry(defaults(), slot(projectId, agent.ownerId))) setEntry(state.personalAgentDefaults, slot(projectId, agent.ownerId), id)
return row
}
function ensure(projectId, ownerId) {
const current = defaultParticipation(projectId, ownerId)
if (current) return current
if (ownEntry(defaults(), slot(projectId, ownerId))) throw failure('Personal agent participation ended; explicitly rejoin or select another agent')
return join(defaultAgent(ownerId), projectId)
}
function migrate() {
const missing = !state.personalAgents || !state.personalAgentParticipations || !state.personalAgentDefaults
tables()
let changed = missing
for (const old of Object.values(state.projectAgents ?? {})) {
if (old.visibility !== 'private' || !ownEntry(state.members, old.memberId) || !old.projectId || old.personalAgentId) continue
const row = join(defaultAgent(old.memberId), old.projectId, { carrierId: old.id })
row.status = old.status === 'retired' ? 'retired' : 'active'
old.personalAgentId = row.agentId
old.participationId = row.id
changed = true
}
return changed
}
function requireParticipation(carrier) {
if (!carrier?.participationId) return // Pre-migration test/legacy carriers remain isolated.
const row = ownEntry(participations(), carrier.participationId)
const agent = row && agentOf(row.agentId)
if (!row || row.status !== 'active' || !agent || agent.status !== 'active' || row.agentId !== carrier.personalAgentId || row.ownerId !== carrier.memberId || row.projectId !== carrier.projectId || row.carrierId !== carrier.id) throw failure('Personal agent participation is unavailable')
}
let tail = Promise.resolve()
async function handle(endpoint, body, actor) {
const ownerId = actor?.memberId
if (!ownerId || !ownEntry(state.members, ownerId) || ownEntry(state.members, ownerId).status === 'disabled' || actor.agentId || actor.watchGrantId || actor.chiefJobId) throw failure('A current user session is required')
if (!await rt.validateHumanActor(actor) || ownEntry(state.members, ownerId)?.status === 'disabled') throw failure('Current user session is unavailable')
const read = ['personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'personal-agent-history'].includes(endpoint)
if (!read && rt.readOnly?.() !== undefined) throw failure('Personal agent storage is read-only', 'bot/read-only')
if (endpoint === 'personal-agent-list' || endpoint === 'personal-agent-participations') {
const limit = body.limit ?? 50
if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw failure('Limit must be 1–200', 'bot/invalid')
const rows = Object.values(endpoint === 'personal-agent-list' ? agents() : participations()).filter(row => row.ownerId === ownerId).sort((a, b) => a.id.localeCompare(b.id))
const index = body.after === undefined ? -1 : rows.findIndex(row => row.id === body.after)
if (body.after !== undefined && index === -1) throw failure('Unknown personal-agent cursor', 'bot/invalid')
const page = rows.slice(index + 1, index + 1 + limit)
return { [endpoint === 'personal-agent-list' ? 'agents' : 'participations']: structuredClone(page), next: index + 1 + limit < rows.length ? page.at(-1).id : null }
}
if (endpoint === 'personal-agent-create') {
const name = bounded(body.name, 'agent name')
const kind = body.kind ?? 'internal'
if (!['internal', 'external'].includes(kind)) throw failure('Invalid personal agent kind', 'bot/invalid')
let connection
if (kind === 'external') {
const provider = bounded(body.provider, 'external provider')
let url
try { url = new URL(body.endpoint) } catch { throw failure('Invalid external endpoint', 'bot/invalid') }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw failure('External endpoint must be HTTPS without credentials, query or fragment', 'bot/invalid')
connection = { provider, endpoint: url.href, protocol: 'flybot-agent/v1', status: 'unverified' }
}
const id = `personal-agent-${randomUUID()}`
const agent = { id, ownerId, name, kind, status: 'active', createdAt: Date.now(), memoryScopeId: id, ...(connection ? { connection } : {}) }
tables()
setEntry(state.personalAgents, id, agent)
try { await rt.save() } catch (error) { delete state.personalAgents[id]; throw error }
return { agent: structuredClone(agent) }
}
const agent = owned(body.agentId, ownerId)
if (endpoint === 'personal-agent-read') return { agent: structuredClone(agent) }
if (endpoint === 'personal-agent-history') {
const row = Object.values(participations()).find(item => item.agentId === agent.id && item.projectId === body.projectId)
if (!row) throw failure('Unknown personal agent participation')
const limit = body.limit ?? 50, offset = body.offset ?? 0
if (!Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0) throw failure('Invalid private history page', 'bot/invalid')
const items = ownEntry(state.projectAgentTranscripts ?? {}, row.carrierId) ?? []
return { agentId: agent.id, participationId: row.id, items: structuredClone(items.slice(offset, offset + limit)), nextOffset: offset + limit < items.length ? offset + limit : null }
}
const prior = structuredClone({ agents: agents(), participations: participations(), defaults: defaults(), grants: state.projectAgentGrants ?? {} })
if (endpoint === 'personal-agent-retire') {
agent.status = 'retired'
agent.retiredAt = Date.now()
for (const row of Object.values(participations())) if (row.agentId === agent.id) row.status = 'retired'
} else {
const project = rt.requireProjectAccess(actor, body.projectId, endpoint === 'personal-agent-leave' ? 'read' : 'create')
if (endpoint === 'personal-agent-join') join(agent, project.id)
else {
const row = Object.values(participations()).find(item => item.agentId === agent.id && item.projectId === project.id)
if (!row || row.status !== 'active' || agent.status !== 'active') throw failure('Active participation is required')
if (endpoint === 'personal-agent-default') setEntry(state.personalAgentDefaults, slot(project.id, ownerId), row.id)
else row.status = 'left'
}
}
const revoked = []
if (endpoint === 'personal-agent-leave' || endpoint === 'personal-agent-retire') {
const carriers = new Set(Object.values(participations()).filter(row => row.agentId === agent.id && row.status !== 'active').map(row => row.carrierId))
for (const grant of Object.values(state.projectAgentGrants ?? {})) {
if (grant.status === 'active' && carriers.has(grant.agentId)) {
grant.status = 'revoked'; grant.revokedAt = Date.now(); grant.generation++
revoked.push(grant.id)
}
}
}
// Roll back only records changed by this operation, and never overwrite a
// concurrent revocation or another identity created by a native carrier.
const changes = []
for (const [key, table] of [['agents', 'personalAgents'], ['participations', 'personalAgentParticipations'], ['defaults', 'personalAgentDefaults'], ['grants', 'projectAgentGrants']]) {
for (const id of new Set([...Object.keys(prior[key]), ...Object.keys(state[table] ?? {})])) {
const before = ownEntry(prior[key], id), after = ownEntry(state[table] ?? {}, id)
if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({ table, id, before, expected: JSON.stringify(after) })
}
}
try { await rt.save() }
catch (error) {
for (const change of changes) {
if (JSON.stringify(ownEntry(state[change.table] ?? {}, change.id)) !== change.expected) continue
if (change.before === undefined) delete state[change.table][change.id]
else setEntry(state[change.table], change.id, change.before)
}
throw error
}
for (const id of revoked) rt.projectAgents.revokeWatchGrant(id)
if (endpoint === 'personal-agent-retire') return { agent: structuredClone(agent) }
const row = Object.values(participations()).find(item => item.agentId === agent.id && item.projectId === body.projectId)
return { participation: structuredClone(row), agent: structuredClone(agent) }
}
rt.personalAgents = { migrate, ensure, agentOf, defaultParticipation, requireParticipation,
carrierId: (projectId, ownerId) => defaultParticipation(projectId, ownerId)?.carrierId ?? legacyId(projectId, ownerId),
endpoint(endpoint, body, actor) {
if (!endpoints.has(endpoint)) return undefined
const run = tail.then(() => handle(endpoint, body, actor))
tail = run.catch(() => {}) // A rejected command must not poison the next one.
return run
},
}
}
Loading
Loading