SLOPSHOPPER

tower-crane

Hand over an issue, a goal or a whole project: plan into tasks, dispatch workers, clean-context review, software gates and merges, with owner decisions queued…

newguardcommandtoaststatustool
v0.1.0MITupdated 2026-10-09agent-sh/tower-crane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tower-crane
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ tower-crane │ ⏺ Read(src/auth.ts) │ Tower Crane stopped watching. Run │ ⎿ Read 6 lines │ /tower-crane-watch to resume. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tower-crane-watch ⎿ tower-crane: Tower Crane pushes events for orchestrator after now into this session as they arrive. Run `tower-crane inbox` ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ tower-crane: tower-crane: watching for orchestrator
README

Tower Crane

Hand an agent anything from one issue to a whole project. Tower Crane keeps the plan, the standard, the review and the merge gates in software, so the agents spend tokens on the work and you watch instead of steering.

  • State in plain files. Tasks, dependencies, owner decisions, evidence and spend live in JSON under .tower-crane/. Only the tower-crane CLI writes them. tower-crane render turns them into a Markdown and HTML sketch you can open anywhere.
  • Any harness. The orchestrator runs in Claude Code, Codex, OpenCode, Antigravity (agy) or pi and dispatches with what that harness has: its own subagents, or tower-crane spawn starting another CLI.
  • A model ladder. Each task has a tier (easy, medium, hard, research), and a ladder in project.json names the harness, model and effort for each tier and for the orchestrator, review and small checks. One field moves every rung to another harness, or each rung picks its own. Edit it with tower-crane ladder set, or in the Settings view of tower-crane serve; ~/.config/tower-crane/config.json holds your primary defaults for new projects and personal fallback routes that apply over every project's rungs.
  • Lean agents. Each role has an agent file that says what it may and may not do. Claude and Codex rungs always run through tower-crane spawn, and each spawned agent gets a fresh config home of its own, so your memories, plugins, hooks, MCP servers and approved commands stay out of it: about 7k (Codex) to 12k (Claude) tokens at start instead of 24k to 42k, and its commands run sandboxed (docs/ladder.md).
  • Review is never self-review. Acceptance needs review evidence from the owner or from a reviewer spawn dispatched for that task, sha and revision, never the submitter.
  • Gates are software. Tests must fail before the change and pass after it, the cleanup tool must report nothing HIGH, CI is read on the exact commit, and merges match the head.
  • Owner decisions do not block. A question blocks only the tasks it names.

Status: under construction. See docs/state.md and docs/cli.md.

Source 1 files
hooks/tower-crane.mjs 153 lines
1// Tower Crane for Claude Code: pushes orchestrator wake-ups into the session.
2// One child, tower-crane wait --follow, prints a line of ids per event; this
3// module turns those lines into prompts, so the session never polls. A push
4// names events only: the session reads current findings with tower-crane inbox.
5
6const TOOL = 'mcp__tower-crane__watch'
7const COMMAND = 'tower-crane-watch'
8
9// The same text lib/events.js notice() gives spawned harness homes.
10function notice(rows, agent) {
11  const lines = rows.map(w => `- ${w.id}: ${w.type}${w.decision ? ` ${w.decision}` : ''}${w.task ? ` ${w.task}` : ''} from ${w.agent}`)
12  const last = rows.at(-1)
13  return [
14    `Tower Crane: ${rows.length} new event${rows.length === 1 ? '' : 's'} for ${agent}.`,
15    ...lines,
16    `Run \`tower-crane inbox\` for findings and resolving commands.${last?.offset ? ` Cursor: ${last.offset}.` : ''}`,
17  ].join('\n')
18}
19
20// One module instance per session load: what it watches and what waits.
21const live = { watch: null, pending: [], steered: [], submitting: false, turn: null }
22
23// A prompt queues until the session is idle and resolves as its turn
24// starts, so what arrives meanwhile goes out together in the next one.
25async function flush($) {
26  if (live.submitting) return
27  live.submitting = true
28  try {
29    while (live.pending.length && live.watch) {
30      const batch = live.pending.splice(0)
31      await $.prompt.submit({ text: notice(batch, live.watch.agent) })
32    }
33  } finally {
34    live.submitting = false
35  }
36}
37
38// A steer joins the running turn at its next model request. A turn that
39// makes no further request never read it, so turn.complete queues it again.
40async function deliver($, w) {
41  if (w.steer && live.turn) {
42    const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: notice([w], live.watch.agent) }] } })
43    if (!appended?.deny) {
44      live.steered.push({ w, step: live.turn.steps })
45      return
46    }
47  }
48  live.pending.push(w)
49  void flush($)
50}
51
52async function follow($, current, child) {
53  let buffer = ''
54  let stderr = ''
55  try {
56    for await (const { stream, text } of child) {
57      if (live.watch !== current) break
58      if (stream === 'stderr') {
59        stderr = (stderr + text).slice(-2000)
60        continue
61      }
62      buffer += text
63      let end
64      while ((end = buffer.indexOf('\n')) !== -1) {
65        const line = buffer.slice(0, end)
66        buffer = buffer.slice(end + 1)
67        let w
68        try {
69          w = JSON.parse(line)
70        } catch {
71          continue
72        }
73        if (typeof w.offset === 'number') current.cursor = String(w.offset)
74        if (w.type !== 'ready' && w.id) await deliver($, w)
75      }
76    }
77  } catch (e) {
78    stderr ||= String(e?.message || e)
79  }
80  if (live.watch !== current) return
81  live.watch = null
82  const why = stderr.trim().split('\n').at(-1)
83  $.ui.toast(`Tower Crane stopped watching${why ? `: ${why}` : ''}. Run /${COMMAND} to resume.`)
84}
85
86async function arm($, input = {}) {
87  const agent = input.agent || (await $.env.get('TOWER_CRANE_AGENT')) || 'orchestrator'
88  const state = input.state || (await $.env.get('TOWER_CRANE_STATE')) || ''
89  const after = input.after !== undefined && input.after !== '' ? String(input.after) : live.watch?.cursor || 'now'
90  const argv = ['node', `${$.plugin.root}/bin/tower-crane.js`, 'wait', '--follow', '--inbox', '--after', after, '--agent', agent, ...(state ? ['--state', state] : [])]
91  const current = { agent, cursor: after }
92  live.watch = current
93  void follow($, current, $.process.spawn({ argv }))
94  $.ui.status(`tower-crane: watching for ${agent}`)
95  return `Tower Crane pushes events for ${agent} after ${after} into this session as they arrive. Run \`tower-crane inbox\` on each wake for findings and resolving commands.`
96}
97
98export function register(on) {
99  on('session.start', async ($, e, next) => {
100    const started = await next(e)
101    await $.tool.register({
102      name: 'watch',
103      description: 'Tower Crane orchestrator: push every orchestrator event (submits, reviews, worker exits and stalls, worker messages, decisions, owner comments, CI, conflicts) into this session as it arrives. Call once after the startup cursor; it replaces background tower-crane wait.',
104      inputSchema: {
105        type: 'object',
106        properties: {
107          after: { type: 'string', description: 'cursor from tower-crane wait --timeout 0, or an event id (default: now, or where the last watch stopped)' },
108          agent: { type: 'string', description: 'identity to wake (default TOWER_CRANE_AGENT, else orchestrator)' },
109          state: { type: 'string', description: 'state directory (default TOWER_CRANE_STATE or the one found from the working directory)' },
110        },
111      },
112    })
113    await $.command.register({ name: COMMAND, description: 'Push Tower Crane orchestrator events into this session', argumentHint: '[cursor]' })
114    if ((await $.env.get('TOWER_CRANE_AGENT')) === 'orchestrator') await arm($)
115    return started
116  })
117
118  on('tool.call', { tool: TOOL }, async ($, e) => ({ result: await arm($, e) }))
119    .catch(() => ({ deny: 'Tower Crane could not start its event watch; run tower-crane wait in the background instead.' }))
120
121  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await arm($, { after: e.args.trim() }) }))
122    .catch(() => ({ text: 'Tower Crane could not start its event watch; run tower-crane wait in the background instead.' }))
123
124  on('turn.start', ($, e, next) => {
125    live.turn = { id: e.turnId, steps: 0 }
126    return next(e)
127  })
128
129  on('turn.step', async function* ($, e, next) {
130    if (live.turn && e.turnId === live.turn.id) live.turn.steps += 1
131    return yield* next(e)
132  })
133
134  on('turn.complete', async ($, e, next) => {
135    const done = await next(e)
136    if (live.turn && e.turnId === live.turn.id) {
137      const unread = live.steered.filter(s => s.step >= live.turn.steps).map(s => s.w)
138      live.steered = []
139      live.turn = null
140      if (unread.length && live.watch) {
141        live.pending.unshift(...unread)
142        void flush($)
143      }
144    }
145    return done
146  })
147
148  on('session.end', ($, e, next) => {
149    live.watch = null
150    return next(e)
151  })
152}
153