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 docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ required checks stay `waiting_external` on the same running job. Generic
worker, test, or merge failures stay failed. Staff assesses a sister only
after every criterion is met. Concurrent GoalRuns persist on the session
snapshot. The blocker panel is Staff chrome, not a chat message.
While a job flies, the Jobs pane may show `Still running.` A status ask
While a job flies, keepalive stays on the job handle / Jobs pane (`Still running.`) — not as feed bubbles. A status ask
restates that line and does not start a new mouth turn. Jobs do not
pixel-click the shared computer. Take control is the operator on that
X display.
Expand Down
27 changes: 26 additions & 1 deletion src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,12 +1159,15 @@ export function createAgentNames(text: string): string[] {
/\b(?:an?\s+)?(?:new\s+)?(?:automaton|bot|agent|mouth)\s+at\s+(?:the\s+)?([A-Za-z][A-Za-z0-9_-]*)/gi,
/\b(?:name|call)\s+(?:the\s+|this\s+)?(?:new\s+)?(?:automaton|bot|agent|mouth)\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
/\b(?:name|call)\s+(?:it|him|her)\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
/\b(?:assign|give)\s+(?:it\s+)?(?:the\s+)?repo\s+of\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
/\brepo\s+of\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
]
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) take(match[1])
}
if (names.length === 0) {
for (const home of parseGithubHomes(text)) take(home.slug.split('/')[1])
for (const home of parseNamedRepoHomes(text)) take(home.slug.split('/')[1] ?? home.slug)
}
return names
}
Expand Down Expand Up @@ -1239,6 +1242,28 @@ export function parseGithubHomes(text: string): RepoHome[] {
return found
}

/** "repo of DiscordOS" / "assign it the DiscordOS repo" — bare product slug, no clone URL yet. */
export function parseNamedRepoHomes(text: string): RepoHome[] {
const found: RepoHome[] = []
const take = (raw: string | undefined) => {
if (!raw) return
const slug = raw.replace(/\s+/g, '')
if (!slug || NAME_STOP.has(slug.toLowerCase())) return
if (!found.some((row) => row.slug.toLowerCase() === slug.toLowerCase())) {
found.push({ slug, url: '' })
}
}
const patterns = [
/\b(?:assign|give)\s+(?:it\s+)?(?:the\s+)?repo\s+of\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
/\brepo\s+of\s+([A-Za-z][A-Za-z0-9_-]*)/gi,
/\b(?:assign|give)\s+(?:it\s+)?(?:the\s+)?([A-Za-z][A-Za-z0-9_-]*)\s+repo\b/gi,
]
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) take(match[1])
}
return found
}

/** "the local dugout repo" attaches a machine checkout. Slug is the bare folder name; no clone URL. */
export function parseLocalHomes(text: string): RepoHome[] {
const found: RepoHome[] = []
Expand Down Expand Up @@ -1353,7 +1378,7 @@ export function issueWorkTargets(text: string, agents: Agent[], focused?: AgentI
}

export function bindHomes(text: string, agents: Agent[]): HomeBind[] {
const homes = [...parseGithubHomes(text), ...parseLocalHomes(text)]
const homes = [...parseGithubHomes(text), ...parseLocalHomes(text), ...parseNamedRepoHomes(text)]
if (homes.length === 0) return []
const roster = agents.filter((agent) => !agent.hidden)
const used = new Set<AgentId>()
Expand Down
19 changes: 4 additions & 15 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,8 +962,8 @@ function deliverTo(
const step = drafts[0]?.work ?? firstAskStep(text)
const kind = ping ? null : (drafts[0]?.kind ?? jobKindForKit(kit, step, prior, products))
if (kind) {
next = speak(next, agentId, ackLine(agent?.name ?? 'Agent'), focused)
next = wakeMouth(next, agentId, 'ack')
// Keepalive lives in MouthWaitBubble / Jobs pane — not feed bubbles.
next = wakeMouth(next, agentId, 'working')
const rows = drafts.length > 0 ? drafts : [{ label: kind, kind, work: step }]
return openGoalRun(next, agentId, focused, mandateText, rows, prior)
}
Expand Down Expand Up @@ -1034,10 +1034,6 @@ function criterionJob(goal: GoalRun, criterion: GoalCriterion, prior: string): J
}
}

function ackLine(name: string): string {
if (name === 'Research') return 'Looking.'
return 'On it.'
}

export function hasUserMessage(session: Session, agentId: AgentId): boolean {
const row = session.threads[agentId]
Expand Down Expand Up @@ -2168,24 +2164,17 @@ export function ownerRunningJob(session: Session, agentId: AgentId): JobHandle |
return running.at(-1)
}

/** Persist keepalive. First note may speak; later ticks stay off the feed. */
/** Persist keepalive on the job handle only — never a feed bubble (MouthWaitBubble covers wait). */
export function noteJobStatus(session: Session, jobId: string, spoken: string): Session {
const job = session.jobs.find((item) => item.id === jobId)
if (!job || job.status !== 'running') return session
const note = isWhitelistedRunningStatus(spoken) ? spoken.trim() : keepAliveStatus(job)
const first = !job.lastNote
const focused = session.activeAgentId
let next: Session = {
return {
...session,
jobs: session.jobs.map((item) =>
item.id === jobId ? { ...item, lastNote: note, updatedAt: Date.now() } : item,
),
}
if (first) {
next = speak(next, job.ownerAgentId, note, focused)
next = wakeMouth(next, job.ownerAgentId, 'working')
}
return next
}

/**
Expand Down
8 changes: 8 additions & 0 deletions tests/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
composerEnterBusy,
shouldQueueSteer,
createAgentNames,
parseNamedRepoHomes,
DEFAULT_AGENTS,
dispatchTargets,
emptyThreads,
Expand Down Expand Up @@ -39,6 +40,7 @@ import {
remainingAsk,
splitAskSteps,
parseLocalHomes,
parseNamedRepoHomes,
parseBoxShellIntent,
jobKindLabel,
createPendingSendView,
Expand Down Expand Up @@ -501,6 +503,12 @@ describe('mouth vs job', () => {
),
).toEqual(['Dugout'])
expect(createAgentNames('make a new automaton and name it Scout')).toEqual(['Scout'])
expect(
createAgentNames('Spin up an automaton and assign it the repo of DiscordOS please'),
).toEqual(['DiscordOS'])
expect(parseNamedRepoHomes('assign it the repo of DiscordOS')).toEqual([
{ slug: 'DiscordOS', url: '' },
])
})

test('local repo mentions bind a machine checkout home onto the named bot', () => {
Expand Down
11 changes: 5 additions & 6 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ describe('teammate session', () => {
s.threads.staff.items.some(
(item) => item.kind === 'msg' && item.from === 'agent' && item.text === 'On it.',
),
).toBe(true)
).toBe(false)
expect(
s.threads.staff.items.some(
(item) => item.kind === 'msg' && item.from === 'agent' && item.text === 'Telling them.',
Expand Down Expand Up @@ -1093,20 +1093,19 @@ describe('teammate session', () => {
expect(pendingMouthTurns(s)[0]?.userText).toBe('how did it go?')
})

test('noteJobStatus speaks once, then only refreshes the handle', () => {
test('noteJobStatus updates the handle only — never a feed bubble', () => {
let s = setActive(fresh(), 'kernel')
s = send(s, 'Kernel, the ledger replay breaks on the composer path.')
const jobId = s.jobs[0].id
const before = s.threads.kernel.items.filter((item) => item.kind === 'msg' && item.from === 'agent').length
s = noteJobStatus(s, jobId, 'Still running.')
expect(s.jobs[0]?.lastNote).toBe('Still running.')
expect(typeof s.jobs[0]?.updatedAt).toBe('number')
expect(s.threads.kernel.mouth).toBe('working')
const spoken = s.threads.kernel.items.filter((item) => item.kind === 'msg' && item.from === 'agent')
expect(spoken.some((item) => item.kind === 'msg' && item.text === 'Still running.')).toBe(true)
const afterFirst = spoken.length
expect(spoken.some((item) => item.kind === 'msg' && item.text === 'Still running.')).toBe(false)
expect(spoken).toHaveLength(before)
s = noteJobStatus(s, jobId, 'Still running.')
const spokenAgain = s.threads.kernel.items.filter((item) => item.kind === 'msg' && item.from === 'agent')
expect(spokenAgain).toHaveLength(afterFirst)
expect(s.jobs[0]?.status).toBe('running')
s = noteJobStatus(s, jobId, 'Done.')
expect(s.jobs[0]?.lastNote).toBe('Still running.')
Expand Down
Loading