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

Craft your own AI office. OffiCraft 是一間跑在你自己 Mac 上的 AI 工作室:你僱幾位常駐的 AI 成員,把事情整件交給他們,在一個網頁控制台裡看他們做到哪、在他們需要你點頭時回一句。跑的就是你機器上那個 Claude Code 或 Codex——你在它上面串好的設定照舊生效(Claude Code 的 skill / plugin / MCP 原封不動全都在)。
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/,控制台裡的「使用說明」分頁讀的也是同一份:
hooks/register.ts 144 lines1import 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