SLOPSHOPPER

officraft

Boots this OffiCraft member, then runs `ocagent listen --deliver-socket`, which writes each event into this session's messaging socket.

newprocess
★ 7v1.0.0MITupdated 2026-10-08pkyosx/OffiCraft/cli/ocwarden/mod
A shopper browsing a rack in a slop shop
README

OffiCraft

Craft your own AI office. OffiCraft 是一間跑在你自己 Mac 上的 AI 工作室:你僱幾位常駐的 AI 成員,把事情整件交給他們,在一個網頁控制台裡看他們做到哪、在他們需要你點頭時回一句。跑的就是你機器上那個 Claude Code 或 Codex——你在它上面串好的設定照舊生效(Claude Code 的 skill / plugin / MCP 原封不動全都在)。

OffiCraft 介紹影片


跟直接開 Claude Code 有什麼不一樣

  • 常駐的成員,不是一次性 session — 成員有穩定身分與記憶,關掉再開還是同一個人,記得你的偏好、專案、上次做到哪;學到的東西會留下來,換手也接得住。你不必每次重講你是誰。
  • 只在該你決定時被叫住 — 需要你拍板的事,成員整理成一張「請示卡」放進一個地方,手機上點一下就決定(要你一次圈好幾項的多選卡才需要勾完按送出),其餘它自己扛。你不用盯著螢幕。
  • 任務進度一目瞭然,接手不掉棒 — 每件事拆成有「完成準則」的節點,現在第幾步、卡在哪、還剩哪些,一眼看到;跑再久也不怕忘,任何接手的成員都從正確的下一步繼續,多位成員還能平行分工。
  • 一個控制台俯視整間工作室 — 辦公室、請示、任務、監控四頁,誰在忙什麼、卡在哪、花了多少,全在一處。
  • 檔案雙向流動,成果一點就看 — 你可以直接把檔案丟給成員;也能請他們做出 HTML 文件、報告、圖表,附在聊天、請示卡或任務上,一點擊就是完整可讀的成果,不用在終端機裡翻。
  • 跨電腦協作 — 成員可以分佈在你的多台電腦上,透過 server 彼此協作;一間工作室橫跨好幾台機器,把它們變成同一間公司的不同辦公室。

安裝(macOS Apple Silicon)

curl -fsSL https://github.com/pkyosx/OffiCraft/releases/latest/download/install.sh | bash

裝完會印出一行一次性設定連結(http://127.0.0.1:7755/?code=…),打開它設個 owner 密碼就進控制台了。完整前置需求、升級與移除見 安裝、升級與移除。

需要 tmux,以及已登入的 claude(Claude Code CLI)或 codex(Codex CLI)至少一種——每位成員底下就是一個跑在 tmux 裡的 Claude Code 或 Codex session。


從手機或外面用

server 只綁 127.0.0.1,預設不對外。要從手機或外面連,開一條你自己的 tunnel(如 cloudflared,會給你一個公開 HTTPS 網址)或走 VPN,設一組夠強的密碼即可。完整步驟(含加到手機主畫面)見 在手機上用控制台。


使用說明

完整的使用者文件在 docs/guide/,控制台裡的「使用說明」分頁讀的也是同一份:

Source 1 files
hooks/register.ts 144 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Written beside this mod by the warden on every spawn (cli/ocwarden/notifymod.go
4// notifyModConfigFile); every path and name below comes from it.
5const CONFIG_FILE = 'officraft.json'
6
7type Config = {
8  boot_prompt: string
9  started_marker: string
10  loaded_marker: string
11  disabled_marker: string
12  booted_marker: string
13  ready_prefixes: string[]
14  listener: { argv: string[]; cwd: string }
15}
16
17export const register: Register = on => {
18  on('session.start', async ($, e, next) => {
19    // Without a config the mod stays silent: the warden then finds no load marker
20    // and fails the start.
21    const config = await readConfig($)
22    // Diagnostics only, first thing and before every check: when the mod does not
23    // load, the warden logs its presence and mtime, which tell a late
24    // session.start from a mod that never ran.
25    if (config !== undefined) await markStarted($, config)
26    const started = await next(e)
27    if (config === undefined) return started
28    // 🔴 The warden gave up on this session and is tearing it down: a boot or a
29    // listener now would mark messages read in a member about to be killed.
30    if (await $.fs.exists(config.disabled_marker)) {
31      $.ui.log('session.start found the warden already gave up on this session; not booting', { to: 'debug' })
32      return started
33    }
34    void boot($, config)
35    return started
36  })
37}
38
39// Detached from session.start, which the engine awaits before the first prompt:
40// the boot prompt is waited on here instead.
41async function boot($: EngineInterface, config: Config): Promise<void> {
42  // 🔴 The boot prompt goes in BEFORE the listener exists: a backlog the listener
43  // delivers on connect queues behind it instead of becoming the member's first
44  // turn (and being marked read before the member ever booted).
45  if (!(await submitted($, config.boot_prompt))) {
46    $.ui.log('the boot prompt was refused; not listening', { to: 'debug' })
47    return
48  }
49  // Tells the warden this attempt booted the member, so it is not restarted.
50  await $.fs.write(config.booted_marker, 'booted\n')
51  await listen($, config)
52}
53
54async function submitted($: EngineInterface, text: string): Promise<boolean> {
55  try {
56    const result = await $.prompt.submit({ text, asUser: true })
57    return result.drop === undefined
58  } catch {
59    return false
60  }
61}
62
63// A failed write only loses the diagnosis; the session goes on.
64async function markStarted($: EngineInterface, config: Config): Promise<void> {
65  try {
66    await $.fs.write(config.started_marker, `${new Date(await $.clock.now()).toISOString()}\n`)
67  } catch (err) {
68    $.ui.log(`cannot write ${config.started_marker} (${String(err)})`, { to: 'debug' })
69  }
70}
71
72async function readConfig($: EngineInterface): Promise<Config | undefined> {
73  const path = `${$.plugin.root}/${CONFIG_FILE}`
74  try {
75    const value: unknown = JSON.parse(await $.fs.read(path))
76    if (isConfig(value)) return value
77    $.ui.log(`${path} is not a notification config; not listening`, { to: 'debug' })
78  } catch (err) {
79    $.ui.log(`cannot read ${path} (${String(err)}); not listening`, { to: 'debug' })
80  }
81  return undefined
82}
83
84function isConfig(value: unknown): value is Config {
85  if (typeof value !== 'object' || value === null) return false
86  const c = value as Record<string, unknown>
87  const listener = c.listener as Record<string, unknown> | undefined
88  return (
89    typeof c.boot_prompt === 'string' &&
90    typeof c.started_marker === 'string' &&
91    typeof c.loaded_marker === 'string' &&
92    typeof c.disabled_marker === 'string' &&
93    typeof c.booted_marker === 'string' &&
94    Array.isArray(c.ready_prefixes) &&
95    c.ready_prefixes.every(p => typeof p === 'string') &&
96    typeof listener === 'object' &&
97    listener !== null &&
98    Array.isArray(listener.argv) &&
99    listener.argv.every(a => typeof a === 'string') &&
100    typeof listener.cwd === 'string'
101  )
102}
103
104// ⚠️ The listener finds this session's messaging socket only through
105// CLAUDE_CODE_MESSAGING_SOCKET/TOKEN inherited from this Claude Code: a spawn
106// that replaced the environment would leave the member deaf.
107//
108// The child lives as long as this loop: leaving it, or the module unloading,
109// kills it. A listener that exits is not restarted: the connection it held
110// disappearing is what makes the station recycle this member.
111//
112// The load marker waits for the listener's first transport line on stderr: one
113// that refused to start prints none, and the missing marker is what fails the
114// warden's start.
115async function listen($: EngineInterface, config: Config): Promise<void> {
116  const child = $.process.spawn({ argv: config.listener.argv, cwd: config.listener.cwd })
117  let pendingErr = ''
118  let ready = false
119  try {
120    for await (const { stream, text } of child) {
121      $.ui.log(text, { to: 'debug' })
122      if (stream !== 'stderr' || ready) continue
123      pendingErr += text
124      let isTransport = false
125      for (let nl = pendingErr.indexOf('\n'); nl >= 0; nl = pendingErr.indexOf('\n')) {
126        const line = pendingErr.slice(0, nl)
127        pendingErr = pendingErr.slice(nl + 1)
128        if (config.ready_prefixes.some(prefix => line.startsWith(prefix))) isTransport = true
129      }
130      if (!isTransport) continue
131      if (await $.fs.exists(config.disabled_marker)) {
132        $.ui.log('the warden gave up on this session; stopping this listener', { to: 'debug' })
133        return
134      }
135      await $.fs.write(config.loaded_marker, 'loaded\n')
136      ready = true
137    }
138    const { code, signal } = await child.result
139    $.ui.log(`ocagent listen exited (code ${code}, signal ${signal})`, { to: 'debug' })
140  } catch (err) {
141    $.ui.log(`ocagent listen stopped: ${String(err)}`, { to: 'debug' })
142  }
143}
144