Skip to content

feat: native Plane task-source + Feishu HITL notifier + worker artifact guard - #514

Draft
lefarcen wants to merge 21 commits into
mainfrom
feat/plane-feishu-hitl
Draft

feat: native Plane task-source + Feishu HITL notifier + worker artifact guard#514
lefarcen wants to merge 21 commits into
mainfrom
feat/plane-feishu-hitl

Conversation

@lefarcen

@lefarcen lefarcen commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Adds native Plane task-source support + a Feishu/generic HITL notifier + a worker build-artifact guard, so Looper can consume tasks from a Plane project (Plane = source of truth, no GitHub issue duplicated), open the implementation PR on GitHub, and ping a webhook when a human is needed.

Draft — bundles 3 logically-separable pieces (happy to split into focused PRs; grouped here because they were built + e2e-validated together). Reviews/structure welcome.

What's in it

  1. feat(notify) — webhook notification channel (Feishu/generic). New delivery channel in the notification gateway; fans out on existing action_required/failure/PR-ready notifications (scheduler untouched). URL injected via an env var named in config (urlEnv) — no secrets in config. format: "feishu"|"generic". Turns Looper's silent "waiting"/"needs attention" into an active ping.
  2. feat(forge) — native Plane task-source provider. kind: "plane" provider: issues/labels/comments/assignees read from Plane's REST API; a plane project's PRs/code stay on GitHub (a plane project's repo is a GitHub repo, so PR-side falls through to the GitHub gateway — no deep two-provider rethreading). Plane creds via tokenEnv. Maps sequence_id→issue number, resolves label UUIDs→names, custom UA (Cloudflare), token redaction.
    • fix(config): preserve workspace/projectId through provider normalization.
    • fix(forge): ListOpenIssues fetch-all-then-filter (a label match can sit beyond Limit).
  3. feat(worktree) — exclude common build artifacts from loop commits. Writes .pnpm-store/, node_modules/, dist/, … to each worktree's info/exclude on creation, so neither the agent's commit nor the fallback commit stages them (an impl push failed pushing a 170MB .pnpm-store/). Worktree-local; never touches the repo's tracked files or .gitignore.

Validated end-to-end

Ran looperd against a Plane project: it natively discovered a Plane work-item (no GitHub issue), the worker implemented it with codex, committed cleanly (no build artifacts), pushed, and opened a clean single-file impl PR — with the Feishu notifier delivering events throughout.

Known follow-ups (non-blocking, called out honestly)

  • Plane discovery re-fetches all work-items + labels every tick (add a short-lived cache).
  • Plane ListOpenIssues hardcodes State:"open" (doesn't drop completed/cancelled via Plane state-groups; relies on the trigger label). Needs a fail-open state-group mapping.
  • One pre-existing env-dependent golden test (internal/api config.get, expects specific tool paths) fails independently of this branch.

🤖 Generated with Claude Code

lefarcen and others added 21 commits July 2, 2026 21:44
…escalation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s on GitHub)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…alization

The provider normalization pre-pass in Normalize() round-trips each partial
provider through cloneProviderConfigs (PartialProviderConfig -> ProviderConfig
-> PartialProviderConfig) but reconstructed the partial with only id/kind/
baseUrl/ghPath/tokenEnv, silently dropping the new workspace/projectId fields.
A valid plane provider then failed validation with "workspace/projectId is
required". Carry both fields through the round-trip and add a LoadFile-path
test asserting they survive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ules, …) from loop commits

Looper's worker commits everything in the worktree (agent commit + looper
fallback commit via `git add -A`). On a pnpm/node repo whose .gitignore misses
a build store (e.g. .pnpm-store/, a 100MB+ content store), that stages the
artifact and GitHub rejects the push for exceeding the 100MB limit.

Write a conservative set of build-artifact patterns to each loop worktree's
git info/exclude on creation/restore. info/exclude is local to the clone and
never touches the repo's tracked files or .gitignore, so it is safe and
upstream-clean. The write is idempotent (no duplicate lines) and best-effort
(a failure never blocks worktree creation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add non-interactive one-click generation of a working Plane task-source config:
  --provider <github|plane> (default github, existing behavior unchanged)
  --plane-base-url / --plane-workspace / --plane-project / --plane-token-env
  --code-repo (or detected from --project-path git origin)
  --trigger-label (default looper:plan)
  --feishu-webhook-env (adds a notifications.webhook feishu block for any provider)

For --provider plane the generated config carries a providers[] plane entry, a
project bound to it (provider + GitHub code repo + local checkout), and
planner/worker discovery on the trigger label (requireAssigneeCurrentUser=false
since Plane assignees are UUIDs). The generated config passes load+validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/plane-provider.md documenting the Plane task-source + Feishu HITL
setup: what it is, the one-command bootstrap, env vars to export, a full
config.json example, how discovery maps Plane labels to roles, and known
follow-ups (discovery cache, state-group filtering, comment updates). Extend
docs/configuration.md provider support to list plane and cross-link the guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Plane task-source + Feishu HITL variant to the looper skill: a new Step 4
bootstrap block with the extra flags, the two env vars to export, and a
references/plane.md with the full flag table, generated config shape, key
gotchas (UUID assignees → requireAssigneeCurrentUser:false), and a discovery
verify step so an agent can one-shot the setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "app" delivery mode to notifications.webhook: instead of an
incoming-webhook text POST, a Feishu app bot posts an interactive card to a
target group chat via the IM API. Config carries mode (webhook|app, default
webhook — unchanged), appIdEnv/appSecretEnv (env var NAMES for the app
credentials), and chatId. App mode fetches a tenant_access_token (cached with
expiry), then POSTs {receive_id, msg_type:interactive, content:<card>} to
/im/v1/messages; the card header template is orange for action_required, red
for failure, blue otherwise. Delivery is best-effort and recorded on the
"feishu_app" channel (success/failed/skipped) with the same enable/level/dedupe
gates as the webhook channel. HTTP calls are injectable for tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nfig-gated)

Foundational, additive pieces for mid-run HITL:
- domain: add awaiting_human loop status + transitions (running<->awaiting_human);
  active/conflicting sets updated. No storage migration (loops.status has no CHECK).
- config: add hitl.enabled gate (default false) with partial/merge/clone; parity
  fixtures updated.
- loops: HITLAsk metadata helpers (Read/Write/Clear) stored in freeform loop
  metadata (no schema change) — carries question/options/sessionId/answer.
- api: POST /api/v1/loops/{seq}/respond validates the loop is awaiting_human,
  stores the human answer on HITL metadata, then transitions awaiting_human->running
  (requeue + scheduler tick) so the next claim resumes the run. Testable core.
- discovery: skip awaiting_human loops in planner/worker re-queue guards (safe no-op
  when HITL is off, since nothing can reach that status).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ated)

Wire the mid-run HITL loop into the worker runner, fully gated by hitl.enabled
(default off = byte-for-byte unchanged behavior):
- prompt: append an ask instruction only when HITL is on.
- suspend: after the agent turn, consume a .looper/ask.json sentinel; if present,
  capture the agent's native session id, persist the ask on loop HITL metadata,
  transition the loop to awaiting_human, cancel the claimed queue item, end the
  run as interrupted (resumable), and send an ask-card.
- resume: on the next run, inject the human's answer into the prompt AND set
  NativeResumePrompt + NativeSessionID so native-resume vendors (codex/claude/
  opencode/cursor) continue the SAME session; falls back to a fresh turn with the
  answer in-prompt otherwise. Worker AgentRunInput + adapter now carry the
  native-resume fields.
- notifier: SendHITLAsk builds an interactive Feishu app-bot card with one button
  per option, each value carrying {loopSeq, answer} for the listener.
- scheduler wires HITLEnabled=cfg.HITL.Enabled + HITLNotify->SendHITLAsk.

Session resume was already supported by internal/agent/executor.go
(NativeSessionID/NativeResumePrompt); this reuses it. Tests: consumeAskSentinel,
suspendForHuman transition+notify, and the ask-card buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-gated)

Add the thin receive-side of the HITL bridge:
- extract deliverHumanAnswer() as the shared respond core (validate
  awaiting_human, store answer, transition running) used by both the
  /respond API and the Feishu receiver.
- POST /api/v1/hitl/feishu: gated by hitl.enabled, answers Feishu's
  url_verification challenge, and on a card-action button click maps
  value.{loopSeq,answer} -> the awaiting loop -> deliverHumanAnswer in-process.

Transport choice: card-action WEBHOOK RECEIVER over looper's existing HTTP
server (point the Feishu app's card-callback URL at <daemon>/api/v1/hitl/feishu)
rather than the larksuite long-connection WS SDK, to avoid a heavy new
dependency. Typed free-text message events are a documented future extension;
button clicks work today.

Tests: card-action delivers the answer + resumes; challenge echo; gated 403
when hitl.enabled is false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Feishu notifier commit added a Webhook field to NotificationConfig, so
GET /config now emits notifications.webhook. Update the frozen contract
fixture to match; this was failing since before the mid-run HITL work.
Codex prints its session id as a styled human line ("\x1b[1msession id:\x1b[0m
<uuid>") with a space in the key, so extractNativeSessionID — which only looked
for JSON-style keys like session_id — never captured it. Native session resume
then silently degraded to a fresh session with the answer in the prompt.

Strip ANSI escapes before matching and add the "session id" key. This makes
mid-run HITL actually resume the same Codex session, and also fixes the
reviewer's re-review-on-head-change native resume for Codex.
Two correctness bugs found in review of the mid-run resume path:

1. persistPullRequestReference merged PR fields into the STALE passed-in loop
   metadata, then wrote it over the fresh record read inside the same tx — so the
   'consumed' flip made earlier in the resumed run was silently reverted to
   'answered'. A later re-run of the loop would then re-inject the already-used
   human decision. Merge into the fresh in-tx metadata instead.

2. The answer was marked 'consumed' BEFORE the resumed agent turn ran. If that
   turn timed out and retried, the retry saw 'consumed', returned no resume
   prompt / session, and silently dropped the human's decision. Split into a
   read-only pendingHumanAnswer (used before the turn) + markHumanAnswerConsumed
   (called only after the turn completes), so a failed turn re-reads the answer
   and a successful one never re-injects it.
…ity)

The /hitl/feishu card-action route delivered human-authored text straight into
an awaiting_human loop's coding-agent session with no origin check — with the
default authMode:none, anyone who could reach the daemon could POST
{loopSeq, answer} and inject an arbitrary decision into any parked loop
(authMode can't help: Feishu's servers can't send a looper Bearer token).

Gate the route on the Feishu app Verification Token, echoed in every callback:
add notifications.webhook.verificationTokenEnv (an env var NAME, since looper is
open source), and require a configured + constant-time-matching token before
delivering an answer. Fail closed: unconfigured token => 403, mismatch => 401.
The url_verification handshake still echoes the challenge (and also enforces the
token when one is configured) so app setup works.
…ecurity

- detectHumanAsk now warns when the ask sentinel exists but can't be read,
  instead of silently proceeding to PR as if the agent never asked.
- docs/plane-provider.md: add the interactive HITL section — app-bot card mode,
  the mid-run ask/answer/resume flow, the /hitl/feishu vs /respond answer paths,
  and the fail-closed verification-token requirement + Encrypt-Key-off + no-option
  caveats.
Previously every app-bot message was an independent interactive card, so a
task's ask + PR-opened stacked as separate cards with no grouping.

- Aggregate per task: the first message for a loop posts a plain-text
  '开始处理 #N …' root, and every later message for that loop is sent as a
  reply_in_thread under it, so a task collapses into one Feishu thread.
  Root ids are cached in-memory per loop on the (long-lived) gateway.
- Only mid-run questions stay interactive cards (they need buttons); PR-opened
  / progress / completion updates are now plain text (Feishu auto-links URLs).
- Thread key is the loop id, plumbed onto HITLAskCard so asks and system
  notifications thread together.

Removes the now-unused buildFeishuCard. Tests updated + a threading test added.
A mid-run question blocks on a human, so it should ping them rather than sit
unread in a busy group. Add notifications.webhook.mentionOpenIds (Feishu
open_ids, plain ids not secrets); SendHITLAsk renders them as card <at id=…>
tags above the question. Empty -> no mention (unchanged).
…he agent

Until now a human could only answer a mid-run ask by clicking an option button
(or the /respond API) — typing a free-text reply in the Feishu thread went
nowhere. Add the inbound half of true two-way HITL:

- Persist the loop<->thread-root mapping (new feishu_threads table + repo) so the
  gateway threads a task's messages AND the callback can reverse-map a reply back
  to the loop. Replaces the gateway's in-memory root map (also survives restart).
- /hitl/feishu now also handles im.message.receive_v1: a text reply typed in a
  known ask thread is reverse-mapped to its loop and delivered as the answer via
  the same deliverHumanAnswer path (verification-token gated; header.token for v2).
- Non-answer chatter (unknown thread, non-text, loop not awaiting) is ignored
  with 200 so Feishu stops retrying and no loop is touched.

Migration 0018 + regenerated legacy fixture; schema-version pins bumped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant