agent-work
GitHubAgent工作注册中心,用于持久化记录智能体产生的各类产出物(如PR、文件等),实现去重、版本管理及卡片渲染。支持扩展新技能提供商、CLI扫描器及工作类型,并处理客户端兼容性兼容问题。
Trigger Scenarios
Install
npx skills add lobehub/lobehub --skill agent-work -g -y
SKILL.md
Frontmatter
{
"name": "agent-work",
"description": "Agent Work registry (works \/ work_versions): how agent outputs — github\/linear entities, files, documents, tasks — get registered, deduped, and rendered as cards, and how to extend it with a new skill provider, a new shell CLI scanner, or a new Work type.",
"user-invocable": false
}
Agent Works
A Work is a durable record of something an agent produced or touched — a GitHub PR/issue, a Linear issue, an entity file (pptx/xlsx/docx/pdf), a document, a task. Works render as cards under the assistant message that produced them and accumulate versions across operations, so the same PR edited twice shows one card with history.
Two tables (packages/database/src/schemas/work.ts):
works— one row per resource. Identity/dedup key:(resourceType, resourceId)within the user/workspace scope.currentVersionIdsoft-references the latest version.work_versions— one row per registration event. Dedup: unique(workId, toolCallId), so a retried registration with the same real tool call id is a no-op. Versions carry provenance (source message, producing tool) and operation-levelcumulativeCost/cumulativeUsage.
Type registry
packages/database/src/models/work/registry.ts is the single registry of Work types (document / external / file / task): WORK_TYPE_ADAPTERS is satisfies Record<WorkType, WorkTypeAdapter>, so a type added to @lobechat/types without an adapter is a compile error.
Read-path compatibility gate: OPT_IN_WORK_TYPES (currently {'file'}) hides newer types from clients that did not opt in (includeFileWorks). Released Electron clients lag by weeks and crash on unknown type descriptors (descriptor.getIcon on undefined), so a request without the opt-in receives exactly the pre-file set. Any NEW Work type must ship behind the same kind of opt-in.
Registration write paths
Four write paths, two timing classes:
| Path | When | Where |
|---|---|---|
Skill structured tools + sandbox runCommand (github / linear) |
at tool execution | WorkModel.handleSkillToolResult, called from server toolExecution and the client executor (registerClientWorkFromIntent.ts on the legacy non-gateway runtime) |
Shell Work scan (hetero codex/claude-code + device lobe-local-system) |
at operation completion | apps/server/src/services/workRegistration/shellWorkRegistration.ts via registerWorksForOperation |
| File Work scan (sandbox entity files) | at operation completion | apps/server/src/services/workRegistration/registerWorksForOperation.ts |
| Task / document works | at creation by their owning feature | WorkModel.registerTask / registerDocument |
Execution-time: skill providers
SKILL_TOOL_RESULT_NORMALIZERS in packages/database/src/models/work/index.ts maps WorkSkillProvider (github, linear; vocabulary in packages/types/src/work.ts) to a normalizer. Both satisfies a Record<WorkSkillProvider, …>, so provider list and normalizer map cannot drift.
A normalizer turns one tool result into ExternalToolWorkOperation | null — null means "not Work-worthy", which is the common case and never an error.
Completion-time: the shell Work scan framework
Heterogeneous CLI agents (codex, claude-code) and the device lobe-local-system tool run CLIs like gh through their own shell surfaces, which never pass the skill-tool hook. registerWorksForOperation recovers their Works at completion from the persisted command text + stdout. Layering:
registerWorksForOperation (registerWorksForOperation.ts)
├─ collectOperationRecords one pass over the operation tree, shared by both scans
├─ registerShellWorks (shellWorkRegistration.ts — the ENGINE, command-agnostic)
│ ├─ SHELL_COMMAND_SOURCES identifier→apiName scoping of shell surfaces
│ ├─ success gate plugin error / state.success===false / state.error → skip
│ ├─ extract {command, exitCode, output} from arguments / state / message content
│ └─ shellWorkScanners/ one file per CLI family
│ ├─ types.ts ShellWorkScanner = { matches, name, register }
│ ├─ github.ts matches: includes('gh ') → workModel.registerShellGithubResult
│ └─ index.ts SHELL_WORK_SCANNERS registry
└─ file Work scan aggregate per-path fold + sandbox export pipeline (NOT a scanner)
Command parsing is split the same way: packages/database/src/models/work/shellCommandParsing.ts holds the command-agnostic layer (POSIX-ish tokenizer, control-operator segmenting, codex /bin/zsh -lc wrapper expansion — parseShellCommandSegments), while githubToolResult.ts holds only the gh-specific parseGhSegment + field mapping.
Cost granularity: execution-time vs completion-time
cumulativeCost / cumulativeUsage are per-version SNAPSHOTS ("spend up to this version"), never deltas — summing them across versions double-counts on every path.
- Execution-time registrations (skill providers) snapshot right after
UsageCounter.accumulateToolfor that call, so create + edit + edit within one run carries an INCREASING series (e.g. $0.30 → $0.70 → $1.20) and step deltas are recoverable. - Completion-time registrations (shell scan, file scan) attach one OPERATION-LEVEL figure — the completing run's terminal total plus terminal child-op totals — to EVERY version registered that round: the scan only has persisted command text + stdout, no per-call intermediate snapshots (hetero CLI usage is aggregated per step by the trace recorder, not per tool call). The same create + edit + edit therefore yields three versions all carrying $1.20, indistinguishable per step.
- Cross-operation is independent: a later run editing the same resource appends a version with THAT run's total only.
Why file Works are not a scanner: their unit is a file path folded across ALL records (multi-edit merge, renames, last-edit provenance), the registration is a heavy IO pipeline (idempotency probe → sandbox export → upload → version → redeploy, bounded concurrency), hetero records are deliberately EXCLUDED (the file lives on the executing device, not the exportable sandbox), and the version key is the synthetic op:${operationId} instead of a real toolCallId. Shoehorning that into ShellWorkScanner would break the interface. If a second aggregate-style scan ever appears, extract an aggregate layer then.
Display anchor & completion marker
A Works card renders only under an assistant message stamped with metadata.work.rootOperationId (the "anchor"). Who stamps it:
- In-process runs:
callLlmFinalizerstamps the round's final assistant message. - Hetero runs never pass that executor, so
registerWorksForOperationstamps the anchor itself when the shell scan registered anything — using the completion'sassistantMessageId, falling back to theparentIdof the last registered tool message (hetero single-step runs persist no final-assistant pointer at all; seeheteroFinish).
The whole scan returns {attempted, failed} and the completion backstop (CompletionLifecycle) writes its idempotency marker only when failed === 0. Anything that must be retried — a thrown registration, a {success: false} anchor stamp, an unresolvable anchor — counts into failed. Registration is retry-safe end to end (probe short-circuits, (workId, toolCallId) guard backs it up), so counting a failure is always the right move.
Key decisions (from the Work PRs)
- Only successful create/edit results become Works (LOBE-10967). Read-only queries, comments, merges/closes, and branch/repo operations are excluded —
gh pr viewprinting an entity URL must NOT register. owner/repo#numberis the canonical github identity, not node_id: the gh CLI surface never returns node_id, and the same entity touched via REST tools and CLI must land on one Work row.- stdout is the source of truth for identity on the CLI path (
gh … create/editprints the entity URL); the command's edit target is the fallback. The LAST gh create/edit segment of a chained command owns the trailing URL. - Persisted URLs are http(s)-allowlisted (
sanitizeExternalUrl): gh stdout / tool results are member-controlled and the URL reachesshell.openExternalon desktop. - The tokenizer is deliberately hand-rolled (no
shell-quotedep): a real parser would expand what must stay literal, and the worst failure mode is skipping a bookkeeping registration. - Error-terminated runs skip the completion scan (same success gate as file Works): a
ghcreate that succeeds before a later terminal failure registers no Work that round. Accepted — bounded cost, missing card only. - Hetero Works register after the terminal snapshot (
agent_runtime_endpublishes beforecompleteOperation), so a hetero-created card appears on the next refetch, not in the terminal snapshot. Accepted for now. - PRs pushed via codex's own GitHub integration are not covered — no shell record exists; text-mention parsing was rejected as too false-positive-prone.
Extension recipes
New skill provider (structured tools), e.g. Notion:
packages/types/src/work.ts— extendWORK_SKILL_PROVIDERS+WORK_PROVIDER_RESOURCE_TYPES(+ resource-type unions).packages/database/src/models/work/<provider>ToolResult.ts— write the normalizer; mirrorlinearToolResult.ts.packages/database/src/models/work/index.ts— add it toSKILL_TOOL_RESULT_NORMALIZERS(thesatisfiesforces this).- Pick a stable cross-surface
resourceId(the github lesson: choose the identity every surface can produce).
New shell CLI scanner, e.g. linear CLI or vercel deploy:
- Add a normalizer in
packages/database/src/models/work/reusingparseShellCommandSegments, plus aWorkModel.registerShell<X>Resultfacade method. - Add
apps/server/src/services/workRegistration/shellWorkScanners/<name>.tswith{ matches, name, register }—matchesstays a cheap substring check. - Append it to
SHELL_WORK_SCANNERSinshellWorkScanners/index.ts. The engine needs zero changes. - Pin the parsing edge cases in
packages/database/src/models/work/__tests__/(wrapper unwrap, chained segments, failed/read-only commands must not register).
New Work type (new card kind):
@lobechat/types— extendWorkTypeand related unions.packages/database/src/models/work/— new adapter module +WORK_TYPE_ADAPTERSentry (compile-enforced).- Client — add the type descriptor for the card UI.
- Add the type to the read-path opt-in gate (
OPT_IN_WORK_TYPESpattern) — released clients throw on unknown types; never ship a new type into the legacy result set.
File map
| Concern | Path |
|---|---|
| Schema | packages/database/src/schemas/work.ts |
| Type registry + read gate | packages/database/src/models/work/registry.ts |
| WorkModel facade + provider normalizer map | packages/database/src/models/work/index.ts |
| gh CLI + structured github normalizer | packages/database/src/models/work/githubToolResult.ts |
| Command-agnostic shell parsing | packages/database/src/models/work/shellCommandParsing.ts |
| Completion scan orchestrator (file + shell + anchor) | apps/server/src/services/workRegistration/registerWorksForOperation.ts |
| Shell scan engine | apps/server/src/services/workRegistration/shellWorkRegistration.ts |
| Desktop-local run scan (client-reported) | apps/server/src/services/workRegistration/localRunWorkRegistration.ts |
| Per-CLI scanners | apps/server/src/services/workRegistration/shellWorkScanners/ |
| Client (non-gateway) registration | src/store/chat/agents/registerClientWorkFromIntent.ts |
Version History
- 29fe043 Current 2026-08-20 18:33


