feat(bg-tasks): coding-from-meetings — auto-implement coding action items (#630)

* feat(bg-tasks): coding-from-meetings — auto-implement coding action items

A background-task flavor that watches for meeting notes, scans them for
actionable coding items, and autonomously implements them in isolated git
worktrees, summarizing results in the task's index.md.

- Emit `meeting.notes_ready` when Fireflies/Granola first write a meeting note
- Add optional `projectId` to BackgroundTask (pins a coding task to a repo)
- New `launch-code-task` builtin tool: per group of items, create a
  worktree-isolated, yolo, direct code session, wrap the prompt in an
  autonomous scaffold, run async, and finalize a per-session row in index.md
- Group code sessions under their meeting heading in index.md
- Summary from the code agent's `## Summary` section; file counts from
  `git diff` vs the worktree fork point (counts committed work, not just dirty)
- Guardrails: self-heal projectId across runs, cap launches per run, and bar
  the bg-task agent from managing/spawning tasks
- UI: "View available templates" -> Coding-from-meetings preset (repo picker,
  prefilled trigger + instructions)

See plan.md for the full design.

* let the copilot able to configure a coding background agent

* Delete plan.md

---------

Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
gagan 2026-06-18 22:56:43 -07:00 committed by GitHub
parent 2f926f8dc0
commit 1f8ac2cf34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 795 additions and 11 deletions

View file

@ -1606,6 +1606,7 @@ export function setupIpcHandlers() {
name: args.name,
instructions: args.instructions,
...(args.triggers ? { triggers: args.triggers } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});

View file

@ -4,6 +4,7 @@ import {
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
Code2, FolderOpen, LayoutTemplate,
} from 'lucide-react'
import type { z } from 'zod'
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
@ -271,7 +272,16 @@ function TriggersEditor({
// New Task dialog
// ---------------------------------------------------------------------------
type DialogMode = 'describe' | 'manual'
type DialogMode = 'describe' | 'manual' | 'templates' | 'coding'
// Prefills for the "Coding from meetings" preset.
const CODING_PRESET = {
name: 'Implement coding items from meetings',
instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team.
Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`,
eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`,
}
function NewTaskDialog({
open,
@ -295,6 +305,9 @@ function NewTaskDialog({
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [triggers, setTriggers] = useState<Triggers | undefined>(undefined)
const [projectId, setProjectId] = useState<string | undefined>(undefined)
const [projectName, setProjectName] = useState<string | undefined>(undefined)
const [addingProject, setAddingProject] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
@ -304,11 +317,64 @@ function NewTaskDialog({
setName('')
setInstructions('')
setTriggers(undefined)
setProjectId(undefined)
setProjectName(undefined)
}
}, [open, copilotEnabled])
// Switch into the coding preset: prefill name/instructions/trigger once.
const enterCodingMode = () => {
setMode('coding')
setName(CODING_PRESET.name)
setInstructions(CODING_PRESET.instructions)
setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria })
}
const pickRepo = async () => {
setAddingProject(true)
try {
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' })
const dir = res.path
if (!dir) return
const added = await window.ipc.invoke('codeProject:add', { path: dir })
if (!added.git?.isGitRepo) {
toast('That folder is not a git repository — coding tasks need one.', 'error')
return
}
setProjectId(added.project.id)
setProjectName(added.project.name)
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setAddingProject(false)
}
}
const canSubmitDescribe = description.trim().length > 0 && !submitting
const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting
const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting
const submitCoding = async () => {
if (!canSubmitCoding) return
setSubmitting(true)
try {
const result = await window.ipc.invoke('bg-task:create', {
name: name.trim(),
instructions: instructions.trim(),
...(triggers ? { triggers } : {}),
...(projectId ? { projectId } : {}),
})
if (result.success && result.slug) {
onCreated(result.slug)
} else {
toast(result.error ?? 'Failed to create task', 'error')
}
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setSubmitting(false)
}
}
const submitDescribe = () => {
if (!canSubmitDescribe || !onCreateWithCopilot) return
@ -359,7 +425,116 @@ function NewTaskDialog({
</button>
</div>
{mode === 'describe' ? (
{(mode === 'describe' || mode === 'manual') && (
<button
type="button"
onClick={() => setMode('templates')}
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
>
<span className="flex items-center gap-2">
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">View available templates</span>
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</button>
)}
{mode === 'templates' ? (
<>
<div className="space-y-2">
{[
{
id: 'coding-from-meetings',
title: 'Coding from meetings',
description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.",
icon: Code2,
onSelect: enterCodingMode,
},
].map(preset => (
<button
key={preset.id}
type="button"
onClick={preset.onSelect}
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
>
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{preset.title}</span>
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
</span>
</button>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
</div>
</>
) : mode === 'coding' ? (
<>
<div className="space-y-4">
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
{projectName ? (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
<span className="flex items-center gap-2 text-[13px]">
<FolderOpen className="size-4 text-muted-foreground" />
<span className="font-medium">{projectName}</span>
</span>
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
</div>
) : (
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
Choose a git repository
</Button>
)}
<p className="mt-1 text-[11px] text-muted-foreground">
Code changes run full-auto in an isolated git worktree your working checkout is never touched.
</p>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
</div>
<div>
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
<TriggersEditor value={triggers} onChange={setTriggers} />
<p className="mt-2 text-[11px] text-muted-foreground">
Prefilled to fire when a meeting's notes become available. Adjust if you want.
</p>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
Create
</Button>
</div>
</div>
</>
) : mode === 'describe' ? (
<>
<Textarea
value={description}

View file

@ -1,5 +1,6 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
@ -20,6 +21,8 @@ import type { CodeModeManager } from "../../code-mode/acp/manager.js";
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
import * as gitService from "../../code-mode/git/service.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the
@ -32,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
provider: true,
}).extend({
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
projectDir: z.string().optional().describe(
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
),
});
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
@ -44,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
}).partial().extend({
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
});
// Turn a user-supplied directory into a registered code project id. Reuses the
// same idempotent registry the Code-section picker writes to (add() validates the
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
// still gets created and the copilot can tell the user what to fix.
function expandHome(p: string): string {
const t = p.trim();
if (t === '~') return os.homedir();
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
return t;
}
async function resolveCodeProject(dirPath: string): Promise<
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
> {
const abs = path.resolve(expandHome(dirPath));
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
try {
project = await projectsRepo.add(abs);
} catch (err) {
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
}
// Worktree isolation needs a real git repo with at least one commit
// (codeSessionService.create throws otherwise). Surface it now as a soft
// warning rather than letting the next run fail silently.
let warning: string | undefined;
try {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
} catch { /* best effort — worktree creation will surface it later */ }
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
}
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
@ -1490,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try {
let projectId: string | undefined;
let warning: string | undefined;
if (input.projectDir) {
const r = await resolveCodeProject(input.projectDir);
if (!r.ok) return { success: false, error: r.error };
projectId = r.projectId;
warning = r.warning;
}
const { createTask } = await import("../../background-tasks/fileops.js");
const result = await createTask({
name: input.name,
instructions: input.instructions,
...(input.triggers ? { triggers: input.triggers } : {}),
...(projectId ? { projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
});
return { success: true, slug: result.slug };
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1511,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try {
const { patchTask } = await import("../../background-tasks/fileops.js");
const { slug, ...partial } = input;
const { slug, projectDir, ...partial } = input;
let warning: string | undefined;
if (projectDir) {
const r = await resolveCodeProject(projectDir);
if (!r.ok) return { success: false, error: r.error };
(partial as { projectId?: string }).projectId = r.projectId;
warning = r.warning;
}
const result = await patchTask(slug, partial);
return { success: true, task: result };
return { success: true, task: result, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1549,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'launch-code-task': {
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
inputSchema: z.object({
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
}),
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
try {
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
const result = await launchCodeTask({
taskSlug: input.taskSlug,
meeting: input.meeting,
title: input.title,
items: input.items,
prompt: input.prompt,
...(input.context ? { context: input.context } : {}),
...(ctx?.runId ? { runId: ctx.runId } : {}),
});
return result;
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({

View file

@ -47,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
If your instructions imply BOTH ("summarize and email it"), do both per run.
CODE MODE implement code via isolated sessions.
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items list them in \`index.md\` as "needs review"; do not code them.
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
- If nothing is actionable, launch nothing and say so in your summary.
# Triggers
The run message tells you which trigger fired and how to interpret it:
@ -76,11 +82,21 @@ The workspace lives at \`${WorkDir}\`.
`;
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
// A running bg-task must not manage bg-tasks: re-running itself risks a
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
// model has done exactly this, dropping the pinned projectId). It implements
// code via `launch-code-task`, not by editing task specs.
const EXCLUDED = new Set([
'executeCommand', // headless: no interactive approval
'code_agent_run', // headless: needs interactive permission UI
'run-background-task-agent',
'create-background-task',
'patch-background-task',
]);
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
// code_agent_run requires an interactive UI for permission approvals — skip it
// here (headless) so it can't hang on an approval no one can answer.
if (name === 'executeCommand' || name === 'code_agent_run') continue;
if (EXCLUDED.has(name)) continue;
tools[name] = { type: 'builtin', name };
}

View file

@ -0,0 +1,333 @@
import fs from 'fs/promises';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
import container from '../di/container.js';
import type { CodeSessionService } from '../code-mode/sessions/service.js';
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
import * as gitService from '../code-mode/git/service.js';
import { extractAgentResponse } from '../agents/utils.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { fetchTask, taskIndexPath } from './fileops.js';
const log = new PrefixLogger('BgTask:Code');
// A code session that hangs (engine wedged, never settles) shouldn't pin a
// "running…" row forever. After this long we finalize from whatever the
// worktree shows and tell the user to check the session.
const MAX_WATCH_MS = 90 * 60 * 1000;
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
// weak model has called this 11+ times in one run. Cap per agent run.
const MAX_LAUNCHES_PER_RUN = 5;
const launchesPerRun = new Map<string, number>();
export interface LaunchCodeTaskArgs {
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
taskSlug: string;
/** The meeting these items came from — sessions are grouped under it in index.md. */
meeting: string;
/** Short human title for this unit of work (one row in index.md). */
title: string;
/** Short description of the item(s) being implemented (for the row). */
items: string;
/** The detailed, task-specific coding instruction written by the agent. */
prompt: string;
/** Optional extra context (e.g. the relevant meeting excerpt). */
context?: string;
/** The bg-task agent's runId — used to cap launches per run. */
runId?: string;
}
export interface LaunchCodeTaskResult {
success: boolean;
sessionId?: string;
branch?: string;
worktreePath?: string;
error?: string;
}
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
// every launch gets a strong, self-contained first message regardless of how
// the agent phrased its part. The session runs full-auto (yolo) with no human.
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
const { prompt, branch, context } = args;
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
${context ? `## Context\n${context}\n\n` : ''}## Task
${prompt}
## Operating rules
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
- Make small, logically-scoped git commits with clear messages as you go.
- Stay in scope don't refactor unrelated code or make sweeping changes the task didn't ask for.
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary never guess in a way that could be destructive.
## When done
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 25 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// The code agent's final message is mostly streamed narration ("Let me view it
// in context…"). We instruct it to end with a `## Summary` section — extract just
// that. Fall back to the last paragraph if it didn't comply.
const SUMMARY_MAX_CHARS = 900;
function cleanSummary(text: string): string {
if (!text) return '';
let body: string;
const idx = text.toLowerCase().lastIndexOf('## summary');
if (idx >= 0) {
body = text.slice(idx + '## summary'.length).trim();
} else {
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
body = paras.length ? paras[paras.length - 1] : text.trim();
}
// Drop empty lines and any leftover heading markers; keep bullet structure.
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
let out = lines.join('\n').trim();
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
return out;
}
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
function quoteSummary(summary: string): string[] {
const cleaned = cleanSummary(summary);
if (!cleaned) return [];
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
}
const SECTION_HEADING = '## Code Sessions';
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
function meetingHeading(meeting: string): string {
return `### 📅 ${meeting}`;
}
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
const { sessionId, title, items, branch, worktreePath } = args;
return [
startMarker(sessionId),
`#### ⏳ ${title}`,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Worktree:** \`${worktreePath}\``,
`- **Session:** \`${sessionId}\` _(running…)_`,
endMarker(sessionId),
].join('\n');
}
// Append a "running" block for a freshly launched session, grouped under its
// meeting's heading inside the Code Sessions section (creating section/heading as
// needed). Serialized via the index.md file lock so concurrent launches don't
// clobber each other.
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
content = '';
}
if (!content.includes(SECTION_HEADING)) {
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
content += `${sep}\n${SECTION_HEADING}\n`;
}
const heading = meetingHeading(meeting);
const lines = content.split('\n');
const headingIdx = lines.findIndex((l) => l.trim() === heading);
if (headingIdx === -1) {
// New meeting group — append heading + block at the end.
if (!content.endsWith('\n')) content += '\n';
content += `\n${heading}\n\n${block}\n`;
} else {
// Existing meeting — insert this block right after the heading so
// sessions stay grouped (newest first within the group).
lines.splice(headingIdx + 1, 0, '', block);
content = lines.join('\n');
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Replace a session's block in place once its run settles.
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
return; // nothing to finalize against
}
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
if (re.test(content)) {
content = content.replace(re, block);
} else {
// The running block went missing (manual edit?) — append the final one.
if (!content.endsWith('\n')) content += '\n';
content += `\n${block}\n`;
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Once the code turn settles, summarize from the worktree diff + the agent's
// final message and rewrite the row.
async function finalizeFromResult(
slug: string,
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
): Promise<void> {
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
let summary = '';
try {
summary = (await extractAgentResponse(sessionId)) ?? '';
} catch { /* best effort */ }
// Count everything the session changed since it forked — including commits
// (the autonomous scaffold tells the agent to commit, so working-tree status
// alone would read as "no changes"). Fall back to working-tree status if we
// don't know the base.
let files: GitStatusFile[] = [];
try {
files = baseBranch
? await gitService.changedSinceBase(worktreePath, baseBranch)
: await gitService.status(worktreePath);
} catch { /* worktree may be gone */ }
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
let heading: string;
let status: string;
if (error) {
heading = `#### ❌ ${title}`;
status = `Failed — ${error}`;
} else if (timedOut) {
heading = `#### ⌛ ${title}`;
status = `Timed out — open the session to check progress`;
} else if (files.length > 0) {
heading = `#### ✅ ${title}`;
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
} else {
heading = `#### ⚠️ ${title}`;
status = `No file changes — open the session for details`;
}
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
const block = [
startMarker(sessionId),
heading,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Session:** \`${sessionId}\``,
`- **Status:** ${status}`,
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
...quoteSummary(summary),
endMarker(sessionId),
].join('\n');
await finalizeBlock(slug, sessionId, block);
}
/**
* Launch a coding session for a bg-task, asynchronously.
*
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
* without waiting, writes a "running" row into the task's index.md, and detaches
* a watcher that finalizes the row once the turn settles. Returns as soon as the
* session exists so the bg-task agent can launch more groups (or finish).
*/
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
if (runId) {
const used = launchesPerRun.get(runId) ?? 0;
if (used >= MAX_LAUNCHES_PER_RUN) {
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
}
launchesPerRun.set(runId, used + 1);
}
const task = await fetchTask(taskSlug);
if (!task) {
return { success: false, error: `Background task '${taskSlug}' not found.` };
}
if (!task.projectId) {
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
}
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (!project) {
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
}
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
let session;
try {
session = await codeSessionService.create({
projectId: project.id,
title,
agent: 'claude',
mode: 'direct',
policy: 'yolo',
isolation: 'worktree',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: `Could not create code session: ${msg}` };
}
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
const baseBranch = session.worktree?.baseBranch ?? undefined;
const worktreePath = session.cwd;
await appendRunningBlock(taskSlug, meeting, runningBlock({
sessionId: session.id, title, items, branch, worktreePath,
}));
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
// Detached: drive the turn to completion, then finalize the index.md row.
// `sendMessage` resolves when the turn settles (it awaits the engine and
// never rejects on engine errors), so we don't need a separate completion
// subscription — but we still cap it with a timeout so a wedged engine can't
// pin the row at "running" forever.
void (async () => {
let timedOut = false;
try {
await Promise.race([
codeSessionService.sendMessage(session.id, wrapped),
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
]);
} catch (err) {
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await finalizeFromResult(taskSlug, {
sessionId: session.id, title, items, branch, worktreePath, timedOut,
...(baseBranch ? { baseBranch } : {}),
});
} catch (err) {
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
}
})();
return { success: true, sessionId: session.id, branch, worktreePath };
}

View file

@ -97,6 +97,7 @@ export interface CreateTaskInput {
name: string;
instructions: string;
triggers?: BackgroundTask['triggers'];
projectId?: string;
model?: string;
provider?: string;
}
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
instructions: input.instructions,
active: true,
...(input.triggers ? { triggers: input.triggers } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
createdAt: new Date().toISOString(),
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
instructions: task.instructions,
active: task.active,
...(task.triggers ? { triggers: task.triggers } : {}),
...(task.projectId ? { projectId: task.projectId } : {}),
createdAt: task.createdAt,
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),

View file

@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
return `
# Coding task
This is a **coding task**. It is pinned to a code repository:
- **Project:** ${project.name}
- **Path:** \`${project.path}\`
Your job this run:
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** bugs to fix, features to build, concrete changes requested.
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
3. **Group** related items together; keep unrelated items separate.
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
If there are no actionable coding items, launch nothing and say so in your final summary.`;
}
function buildMessage(
slug: string,
task: BackgroundTask,
trigger: BackgroundTaskTriggerType,
context?: string,
codeProject?: { id: string; path: string; name: string },
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
@ -50,7 +70,7 @@ function buildMessage(
**Instructions:**
${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
return baseMessage + buildTriggerBlock({
trigger,
@ -103,6 +123,20 @@ export async function runBackgroundTask(
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
// by an LLM call to create-background-task) should fall through to the
// default just like undefined does.
// Coding tasks carry a pinned code project — resolve it so the run
// message can tell the agent which repo to work in.
let codeProject: { id: string; path: string; name: string } | undefined;
if (task.projectId) {
try {
const { default: container } = await import('../di/container.js');
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
} catch (err) {
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const model = task.model || await getBackgroundTaskAgentModel();
const agentRun = await createRun({
agentId: 'background-task-agent',
@ -129,9 +163,16 @@ export async function runBackgroundTask(
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
// the previous successful run stays visible in the UI even while this
// new run is in-flight or fails.
// `projectId` is runtime-owned config the agent must never lose. A weak
// model can clobber task.yaml mid-run (despite "never touch this"), which
// would silently disable coding on later runs — so we re-assert it on
// every patch to self-heal.
const heal = task.projectId ? { projectId: task.projectId } : {};
await patchTask(slug, {
lastAttemptAt: startedAt,
lastRunId: runId,
...heal,
});
backgroundTaskBus.publish({
@ -142,7 +183,7 @@ export async function runBackgroundTask(
});
try {
await createMessage(runId, buildMessage(slug, task, trigger, context));
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
await waitForRunCompletion(runId, { throwOnError: true });
const summary = await extractAgentResponse(runId);
@ -151,6 +192,7 @@ export async function runBackgroundTask(
lastRunAt: new Date().toISOString(),
lastRunSummary: summary ?? undefined,
lastRunError: undefined,
...heal,
});
log.log(`${slug} — done summary="${truncate(summary)}"`);
@ -171,7 +213,7 @@ export async function runBackgroundTask(
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
// retry-storming.
try {
await patchTask(slug, { lastRunError: msg });
await patchTask(slug, { lastRunError: msg, ...heal });
} catch {
// don't mask the original error
}

View file

@ -161,6 +161,67 @@ export async function status(cwd: string): Promise<GitStatusFile[]> {
return result;
}
// Everything this worktree's branch changed since it forked from `baseRef` —
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
// so it misses work an agent committed; this is what you want for a session
// summary. Counts come from numstat, states from name-status, merged by path.
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
let forkPoint = baseRef;
try {
forkPoint = (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
} catch {
forkPoint = baseRef;
}
const stateByPath = new Map<string, GitFileState>();
try {
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
const parts = ns.split('\0');
for (let i = 0; i < parts.length; i++) {
const code = parts[i];
if (!code) continue;
const letter = code[0];
if (letter === 'R' || letter === 'C') {
// rename/copy: "<code>\0<old>\0<new>"
const newPath = parts[i + 2];
i += 2;
if (newPath) stateByPath.set(newPath, 'renamed');
} else {
const p = parts[i + 1];
i += 1;
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
}
}
} catch {
// bad ref / no commits — leave states empty
}
const result: GitStatusFile[] = [];
try {
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
const rows = numstat.split('\0');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
if (!m) continue;
const insertions = m[1] === '-' ? null : Number(m[1]);
const deletions = m[2] === '-' ? null : Number(m[2]);
let filePath = m[3];
if (!filePath) {
// rename form: old and new paths follow as separate tokens
i += 2;
filePath = rows[i] ?? '';
}
if (!filePath) continue;
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
}
} catch {
// bad ref / no commits — nothing to report
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;

View file

@ -6,6 +6,7 @@ import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { publishMeetingNotesReadyEvent } from '../meeting-events.js';
import {
GetDocumentsResponse,
SyncState,
@ -439,6 +440,14 @@ async function syncNotes(): Promise<void> {
} else {
console.log(`[Granola] Saved: ${filename}`);
newCount++;
// First-time write only — don't re-fire on later edits to the
// same note (Granola notes update live).
await publishMeetingNotesReadyEvent({
source: 'granola',
title: docTitle,
filePath,
when: docDate.toISOString(),
});
}
// Update state

View file

@ -0,0 +1,37 @@
import { createEvent } from '../events/producer.js';
// Emitted when a meeting note/transcript is first written to disk (Fireflies,
// Granola, …). This is the natural "the meeting is over and we have content"
// signal — unlike a calendar end-time, the notes actually exist now. Coding
// background tasks subscribe to it (via eventMatchCriteria) to scan freshly
// landed notes for actionable coding items.
//
// Fire ONCE per meeting (on first write), not on every re-sync/edit, so a note
// that keeps updating doesn't re-trigger downstream agents.
export async function publishMeetingNotesReadyEvent(args: {
source: string;
title: string;
filePath: string;
when?: string;
}): Promise<void> {
const { source, title, filePath, when } = args;
try {
await createEvent({
source,
type: 'meeting.notes_ready',
createdAt: new Date().toISOString(),
payload: [
`# Meeting notes ready`,
``,
`**Title:** ${title}`,
when ? `**When:** ${when}` : ``,
`**Source:** ${source}`,
`**Notes file:** \`${filePath}\``,
``,
`The full meeting notes/transcript are at the path above. They may contain coding action items (bugs to fix, features to build, changes requested). Read the file to decide whether to act.`,
].filter(Boolean).join('\n'),
});
} catch (err) {
console.error(`[${source}] Failed to publish meeting.notes_ready event:`, err);
}
}

View file

@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
import { FirefliesClientFactory } from './fireflies-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { publishMeetingNotesReadyEvent } from './meeting-events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
@ -583,6 +584,15 @@ async function syncMeetings() {
fs.writeFileSync(filePath, markdown);
console.log(`[Fireflies] Saved: ${filename}`);
// First-time write for this meeting (guarded by syncedIds above) —
// signal that fresh notes are available for downstream agents.
await publishMeetingNotesReadyEvent({
source: 'fireflies',
title: meetingData.title || 'untitled',
filePath,
...(meetingData.dateString ? { when: meetingData.dateString } : {}),
});
syncedIds.add(meetingId);
newCount++;
processedInBatch++;

View file

@ -27,6 +27,10 @@ export type BackgroundTask = {
instructions: string;
active: boolean;
triggers?: Triggers;
// When set, this is a *coding* task: it implements code in the pinned code
// project (a registered repo) via the `launch-code-task` tool, each launch
// running in its own isolated worktree. Omit for ordinary OUTPUT/ACTION tasks.
projectId?: string;
model?: string;
provider?: string;
createdAt: string;
@ -48,6 +52,7 @@ export type BackgroundTaskSummary = {
instructions: string;
active: boolean;
triggers?: Triggers;
projectId?: string;
createdAt: string;
lastAttemptAt?: string;
lastRunId?: string;
@ -56,11 +61,14 @@ export type BackgroundTaskSummary = {
lastRunError?: string;
};
// NOTE: keep `BackgroundTaskSummary` (above) and `BackgroundTask` (top) in sync.
export const BackgroundTaskSchema = z.object({
name: z.string().min(1).describe('User-facing display name.'),
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
@ -77,6 +85,7 @@ export const BackgroundTaskSummarySchema = z.object({
instructions: z.string(),
active: z.boolean(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
createdAt: z.string(),
lastAttemptAt: z.string().optional(),
lastRunId: z.string().optional(),

View file

@ -1299,6 +1299,7 @@ const ipcSchemas = {
name: z.string(),
instructions: z.string(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),