omp packages

pi-arc-lifecycle0.10.0

pi extension that runs workspace scripts and loads context files at Arc lifecycle stages

GitHub Actions Published 12 hours ago ~1.3K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-arc-lifecycle

A pi extension for Arc workspaces. On every agent launch it reads /workspace/arc-lifecycle.yaml and:

  • appends context files (each with an optional verbatim prefix/suffix) to the system prompt
  • runs scripts at five stages: start (first launch ever), resume (first launch after a reboot), before_agent_start, agent_before_settle and agent_settled (each user message)

Across a workspace's life

Timeline of the stages across two boots of one workspace

start runs once, on the very first message. Each later boot gets one resume, on its first message. Nothing runs at boot itself; the stage fires when the next message launches pi.

What one pi launch does

Flow of one pi launch, from taking the lock to pi exiting

The lock covers every script run, so two sessions sharing the workspace can't both run start. Successes stay silent; other failures are reported once, on the next message.

Config

The yaml is validated once per edit and cached as /workspace/.arc-lifecycle/config.json; the cache is rebuilt on the next message after the yaml changes. A start script added by an edit runs on the next message, even in a workspace that has already started; a resume script added by an edit also runs then if the workspace has restarted since start. The full format is the ArcLifecycleConfig type in src/config.ts; every context entry and script uses exactly one of path/source/text (context) or run/source (scripts).

version: 1
default_cwd: /workspace # optional; this is the default
thinking_level: high # optional; /effort <level> overrides per session
skill_mode: claudecode # optional; default pi
max_sleep_seconds: 5 # optional; refuse bash commands that start with a longer sleep

context: # appended to the system prompt, every message
  - path: /workspace/Demeter/some/deeply/nested/dir/AGENTS.md
    prefix: "## Project rules\nFollow these for all work in Demeter.\n\n"
    suffix: "\n\n(End of project rules.)"
  - source: # fetched once, during start
      repo: GanazHQ/agent-config
      ref: main
      path: arc/AGENTS.md
    enable_in_subagents: true # default false
  - text: Use /workspace/scratchpad for temporary files. # inline text
  - path: /workspace/Demeter/.claude/personal/kyle.md
    users: [kyle@ganaz.com] # only in sessions kyle@ganaz.com started

skills: # skill directories (or directories of skills) loaded into pi
  - path: /workspace/Demeter/.claude/personal/kyle/skills
    users: [kyle@ganaz.com] # optional, as for context

scripts:
  - name: uv-global
    lifecycle: [start, resume]
    run: mise use -g uv@0.11.3 # one-liner, run with bash -c

  - name: dev-db
    lifecycle: [start, resume]
    source: # fetched once, during start
      repo: GanazHQ/Demeter
      ref: master # branch or commit SHA
      path: devops/arc/db-up.sh
    blocking: false # default true
    timeout: 900 # seconds; default 300

  - name: tool-path
    lifecycle: [start, resume]
    run: echo "PATH=/workspace/.tools/extra/bin:$PATH" >> "$ARC_LIFECYCLE_ENV"

  - name: type-check
    lifecycle: [agent_before_settle]
    cwd: Demeter/node/apps/hr-ui # relative to default_cwd
    run: pnpm -s type-check # silent on success
    timeout: 120
    max_continuations: 2 # default 1

  - name: changelog-nudge
    lifecycle: [agent_before_settle]
    run: git -C /workspace/Demeter diff --quiet -- node/apps/backend/src/api || echo "You changed the API. Update CHANGELOG.md if you haven't."

  - name: format-changed
    lifecycle: [agent_settled]
    run: cd /workspace/Demeter && git diff --name-only | xargs -r npx prettier --write
    timeout: 60

Script graphs

Written as a map keyed by name instead of a list, scripts runs as a graph: every script starts as soon as everything in its depends_on has passed, so independent scripts run at the same time. A script without depends_on starts right away. The list form keeps running top to bottom, each script waiting for the one before it (except blocking: false scripts, which start in the background and don't hold up the next).

max_parallel: 4 # optional; blocking map-form scripts running at once, unlimited by default

scripts:
  uv-global:
    lifecycle: [start, resume]
    run: mise use -g uv@0.11.3
  mise-install: # waits for uv-global; gh-stack runs alongside both
    lifecycle: [start, resume]
    depends_on: [uv-global]
    run: mise install
  gh-stack:
    lifecycle: [start, resume]
    run: gh extension install github/gh-stack

  type-check:
    lifecycle: [agent_before_settle]
    run: pnpm -s type-check
  tests: # skipped, not run, while type-check fails
    lifecycle: [agent_before_settle]
    depends_on: [type-check]
    run: pnpm -s test
  • Each stage is its own graph. A dependency must list every stage its dependent lists, run in subagents if its dependent does, and not use blocking: false (nothing can wait for a background script). Unknown names and cycles are config errors.
  • A dependency passes when it exits 0 within its timeout. Otherwise everything that depends on it is skipped, and each skip is reported like a failure (start/resume skips are marked done).
  • Until its stage finishes, a script's $ARC_LIFECYCLE_ENV values reach only scripts that depend on it (directly or not), merged in yaml order. Once the stage finishes, every script's values apply to later stages and the agent, in yaml order, as with a list.
  • agent_before_settle waits for the whole graph, then sends output in yaml order. A skip is mentioned to the agent only alongside the output of the script that caused it.
  • If a launch dies partway through a start/resume graph, the next launch runs what's left; scripts already done count as passed or failed as recorded in state.json.

A longer design write-up is in docs/design.html (repo only, not published).

Behavior worth knowing

  • start/resume scripts are marked done whether they pass or fail; failures are reported to the agent once, on the next message.
  • Any output from an agent_before_settle script is sent to the agent, which gets another turn, up to max_continuations (default 1) times per script per message. After that its output is only logged (a failing exit is still reported on the next message). Keep these scripts silent on success.
  • Lines of KEY=VALUE written to $ARC_LIFECYCLE_ENV become environment variables for later scripts (in a graph, only its dependents until the stage finishes) and for the agent's bash tool on every later message.
  • Blocking scripts must not leave background processes holding their output; use blocking: false (allowed only for start/resume scripts) for long-running work.
  • Workflow agents (in-process sessions) only get items with enable_in_subagents: true, and never run start/resume. Arc's subagent children don't load extensions at all.
  • thinking_level (optional, top level) sets pi's thinking level on every main-session message, overriding Arc's default. Only when it's set, a /effort <level> command is registered: it overrides the level for that session until /effort with no argument resets it.
  • users (on context entries and skills) limits an entry to sessions started by those emails; without it the entry applies to everyone. The starter is ARC_SESSION_USER_EMAIL as seen on the session's first launch, saved in .arc-lifecycle/session-users/<ARC_SESSION_ID> so it stays the same if the variable later changes. Sessions with no recorded starter only get entries without users.
  • resume_notice (optional, top level) is sent to a session, once, on its first message after the workspace restarted, e.g. to say that /tmp, ~ and Docker containers are gone. Each session is told separately; a session's first message never gets it.
  • max_sleep_seconds (optional, top level) refuses a bash call whose command starts with sleep (after any cd … && or cd …;) for longer than that many seconds. The model is told to end its turn to wait for a command it started (subagents and workflow agents, whose turn can't resume, aren't), or to run one bounded loop that exits on a condition, such as timeout 900 sh -c 'until <check>; do sleep 5; done'; a sleep inside a loop like that is allowed. (Claude Code's harness refuses these sleeps on its own; this brings the same guard to pi.) Without it, agents in Arc chain sleep 58 and background_tasks output calls to wait for anything slower than the bash tool's 60-second blocking limit.
  • Arc copies /workspace/.pi into a new .pi/resources/snapshot-<random>/ for every pi launch, so skills loaded from there would get a new <location> in the system prompt on every message, and nothing after it could be read from the prompt cache. By default each such skill is listed at the same file under $ARC_LIFECYCLE_ROOT/.pi/skills instead (if it exists there), even without an arc-lifecycle.yaml. Set disable_system_prompt_skill_stabilization: true (optional, top level) to list the snapshot paths as pi loaded them.
  • skill_mode: claudecode (optional, top level; default pi) loads skills the way Claude Code does, since Claude models are trained on it. Its wording and layout are copied from what Claude Code 2.1.293 sends the Anthropic API:
    • The system prompt drops pi's skill list. After the user's message, a system message lists skills not yet listed in the context by name and description (none marked disable-model-invocation), plus a note for each skill the message names mid-sentence (/name). Models without mid-conversation system messages get it as <system-reminder> text instead. The yaml's context joins the system prompt on each request, so that message isn't dropped with it.
    • The model loads a skill with a Skill tool (skill, args). The tool result only says Launching skill: <name>; the skill's folder and body, with $ARGUMENTS, $0, ${CLAUDE_SKILL_DIR} and the like filled in, follow as user text in the same turn. Calling it again with the same body says it's already loaded.
    • A message starting with /name args (or pi's /skill:name args) arrives as Claude Code's <command-name> header and the skill body.
    • When a compaction summarizes listings away, the missing skills' listing is frozen into the session at that moment and sent right after the summary on every request until the next compaction. Skills loaded before the compaction aren't re-sent; the next Skill call loads the full body again.
    • args of "null" count as no arguments: Opus sometimes sends that string instead of omitting the optional field, which Claude Code would pass through as ARGUMENTS: null.
    • A skill marked disable-model-invocation runs from the Skill tool only when a person typed /name in the main session's current message, never from a subagent or workflow task.
    • Not supported: context: fork, allowed-tools, per-skill model, inline !`command` and Claude Code's "invoked earlier" re-injection of loaded skills after compaction. If an earlier extension already replaced the system prompt, its copy of pi's skill list stays.
  • Hook runs are serialized with the util-linux flock binary (Linux only). Scripts with blocking: false keep running after the lock is released.

Development

pnpm i
pnpm test        # vitest; never calls a model
pnpm type-check
pnpm format

To release, bump version in package.json in your PR. When it merges to master, .github/workflows/publish-arc-lifecycle.yaml stages it on npm with trusted publishing (OIDC, GitHub environment npmjs-pi-arc-lifecycle); a maintainer then approves it on npmjs.com (or npm stage approve <id>) with 2FA. ./publish.sh is the manual fallback.

ARC_LIFECYCLE_ROOT (default /workspace) and ARC_LIFECYCLE_BOOT_ID exist so tests can use a scratch directory and fake a reboot.