bobi 0.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. bobi/__init__.py +0 -0
  2. bobi/__main__.py +3 -0
  3. bobi/__version__.py +6 -0
  4. bobi/_deploy/Dockerfile +263 -0
  5. bobi/_deploy/docker/docker-entrypoint.sh +377 -0
  6. bobi/_deploy/docker/healthcheck.sh +31 -0
  7. bobi/_deploy/docker/noop-deps.sh +7 -0
  8. bobi/_deploy/scripts/destroy-instance.sh +52 -0
  9. bobi/_deploy/scripts/fleet.sh +99 -0
  10. bobi/_deploy/scripts/provision-instance.sh +477 -0
  11. bobi/agentui/__init__.py +12 -0
  12. bobi/agentui/remote.py +167 -0
  13. bobi/agentui/server.py +414 -0
  14. bobi/agentui/static/app.css +342 -0
  15. bobi/agentui/static/app.js +0 -0
  16. bobi/agentui/static/index.html +73 -0
  17. bobi/auth_bootstrap.py +386 -0
  18. bobi/brain/__init__.py +97 -0
  19. bobi/brain/base.py +135 -0
  20. bobi/brain/claude.py +213 -0
  21. bobi/brain/codex.py +229 -0
  22. bobi/browser.py +311 -0
  23. bobi/build_render.py +253 -0
  24. bobi/cli.py +3365 -0
  25. bobi/compose.py +760 -0
  26. bobi/concurrency_semaphore.py +87 -0
  27. bobi/config.py +473 -0
  28. bobi/costs.py +164 -0
  29. bobi/deploy.py +1280 -0
  30. bobi/doctor.py +384 -0
  31. bobi/env.py +121 -0
  32. bobi/event-server/package-lock.json +5989 -0
  33. bobi/event-server/package.json +28 -0
  34. bobi/event-server/src/adapters/chat-sdk-slack.ts +137 -0
  35. bobi/event-server/src/adapters/github.ts +108 -0
  36. bobi/event-server/src/adapters/index.ts +11 -0
  37. bobi/event-server/src/adapters/linear.ts +43 -0
  38. bobi/event-server/src/adapters/slack.ts +164 -0
  39. bobi/event-server/src/circuit-breaker.ts +326 -0
  40. bobi/event-server/src/core.ts +1310 -0
  41. bobi/event-server/src/dashboard/index.html +1208 -0
  42. bobi/event-server/src/deployment-session.ts +234 -0
  43. bobi/event-server/src/index.ts +529 -0
  44. bobi/event-server/src/internal-auth.ts +64 -0
  45. bobi/event-server/src/local.ts +680 -0
  46. bobi/event-server/tsconfig.json +42 -0
  47. bobi/events/__init__.py +27 -0
  48. bobi/events/adapters.py +265 -0
  49. bobi/events/channels.py +149 -0
  50. bobi/events/client.py +460 -0
  51. bobi/events/drain.py +275 -0
  52. bobi/events/publish.py +146 -0
  53. bobi/events/reactor.py +291 -0
  54. bobi/events/server.py +616 -0
  55. bobi/events/signing.py +60 -0
  56. bobi/events/subscriptions.py +95 -0
  57. bobi/history.py +399 -0
  58. bobi/http.py +138 -0
  59. bobi/inbox.py +344 -0
  60. bobi/kb/__init__.py +0 -0
  61. bobi/kb/embedder.py +149 -0
  62. bobi/kb/sidecar.py +160 -0
  63. bobi/kb/store.py +560 -0
  64. bobi/manager_health.py +181 -0
  65. bobi/mcp/__init__.py +1 -0
  66. bobi/memory.py +151 -0
  67. bobi/monitors/__init__.py +27 -0
  68. bobi/monitors/curator.py +231 -0
  69. bobi/monitors/framework_defaults.yaml +21 -0
  70. bobi/monitors/registry.py +181 -0
  71. bobi/monitors/scheduler.py +802 -0
  72. bobi/monitors/schema.py +253 -0
  73. bobi/monitors/script_cache_checks.py +1139 -0
  74. bobi/monitors/tool_checks.py +279 -0
  75. bobi/paths.py +248 -0
  76. bobi/prompts/__init__.py +16 -0
  77. bobi/prompts/base.md +171 -0
  78. bobi/prompts/curator.md +110 -0
  79. bobi/prompts/resolver.py +307 -0
  80. bobi/prompts/setup.md +166 -0
  81. bobi/reconcile.py +184 -0
  82. bobi/registry.py +455 -0
  83. bobi/scaffold.py +422 -0
  84. bobi/sdk.py +449 -0
  85. bobi/session.py +897 -0
  86. bobi/setup/__init__.py +24 -0
  87. bobi/setup/actions.py +393 -0
  88. bobi/setup/authoring.py +646 -0
  89. bobi/setup/automate.py +87 -0
  90. bobi/setup/digestion.py +317 -0
  91. bobi/setup/harness.py +118 -0
  92. bobi/setup/llm.py +139 -0
  93. bobi/setup/mcp_detect.py +544 -0
  94. bobi/setup/mcp_probe.py +352 -0
  95. bobi/setup/mcp_registry.py +149 -0
  96. bobi/setup/open_mode.py +289 -0
  97. bobi/setup/services.py +652 -0
  98. bobi/setup/state.py +233 -0
  99. bobi/setup/venn_cli.py +132 -0
  100. bobi/setup/webui/__init__.py +6 -0
  101. bobi/setup/webui/server.py +1165 -0
  102. bobi/setup/webui/static/app.css +634 -0
  103. bobi/setup/webui/static/app.js +1862 -0
  104. bobi/setup/webui/static/bobi-mark.svg +9 -0
  105. bobi/setup/webui/static/index.html +31 -0
  106. bobi/skills/bobi.md +129 -0
  107. bobi/skills/create-agent.md +306 -0
  108. bobi/skills/linear-setup.md +71 -0
  109. bobi/skills/slack-setup.md +145 -0
  110. bobi/slack.py +565 -0
  111. bobi/slack_manifest.py +65 -0
  112. bobi/spend_governor.py +110 -0
  113. bobi/state_version.py +80 -0
  114. bobi/subagent.py +1655 -0
  115. bobi/templates/slack-app.manifest.yaml +48 -0
  116. bobi/tool_library/codex/guide.md +44 -0
  117. bobi/tool_library/codex/tool.yaml +12 -0
  118. bobi/tool_library/openai/guide.md +36 -0
  119. bobi/tool_library/openai/tool.yaml +19 -0
  120. bobi/tool_library/venn/guide.md +44 -0
  121. bobi/tool_library/venn/tool.yaml +19 -0
  122. bobi/tool_library.py +198 -0
  123. bobi/transient.py +50 -0
  124. bobi/validate.py +373 -0
  125. bobi/venn.py +167 -0
  126. bobi/watchdog.py +488 -0
  127. bobi/workflow/__init__.py +8 -0
  128. bobi/workflow/cleanup.py +152 -0
  129. bobi/workflow/orchestrator.py +846 -0
  130. bobi/workflow/schema.py +105 -0
  131. bobi/workflow/state.py +157 -0
  132. bobi/workflow/triggers.py +80 -0
  133. bobi/workflow/variables.py +201 -0
  134. bobi-0.35.0.dist-info/METADATA +40 -0
  135. bobi-0.35.0.dist-info/RECORD +137 -0
  136. bobi-0.35.0.dist-info/WHEEL +4 -0
  137. bobi-0.35.0.dist-info/entry_points.txt +2 -0
bobi/__init__.py ADDED
File without changes
bobi/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bobi.cli import main
2
+
3
+ main()
bobi/__version__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("bobi")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,263 @@
1
+ # syntax=docker/dockerfile:1
2
+ #
3
+ # bobi instance image (containerized-8 / #338).
4
+ #
5
+ # ONE image, every tenant. Identity lives entirely in the mounted volume
6
+ # (project root + $HOME) and env vars — see docs/design/CONTAINERIZED_INSTANCES.md
7
+ # §2 (the instance contract). Nothing reaches in; the manager reaches out to the
8
+ # event server over WSS only.
9
+ #
10
+ # ONE Dockerfile, three BUILD modes (BOBI_BUILD build-arg) — the runtime stage
11
+ # is shared, so there's nothing to keep in sync:
12
+ # * source (default) — build the wheel from a repo checkout (`COPY . .`). Dev +
13
+ # the repo's own CI, so unreleased branch code is tested.
14
+ # * pypi — install a published `bobi==$BOBI_VERSION` from
15
+ # PyPI; the build context is just this file + docker/. This is what binary-mode
16
+ # `bobi deploy` uses, so deploying needs no checkout (DEPLOY_INTERFACE.md).
17
+ # * wheel — install a PREBUILT local wheel staged in dist/. The release
18
+ # pipeline (release.yml) builds the wheel once, then bakes THAT artifact so the
19
+ # canary smokes — and the fleet runs — the exact bytes published to PyPI.
20
+ #
21
+ # Design-mandated properties (CONTAINERIZED_INSTANCES.md §5, §6.1, §10 C8):
22
+ # * Runs the agent as a NON-ROOT user. Claude Code refuses bypassPermissions
23
+ # as root unless IS_SANDBOX=1; we drop privileges to `bobi` first.
24
+ # * No Node.js. The `claude` CLI is the native standalone binary (no npm).
25
+ # * fastembed model baked into the image at build (cold-start speed; immutable).
26
+ # * Pinned `claude` CLI; auto-updater disabled so the image version is frozen.
27
+ # * `bobi agent <name> start --foreground` as the entrypoint (C2); no tini — Fly's
28
+ # init is PID 1 (tini-on-Fly is a known boot-failure trigger).
29
+ #
30
+ # Build:
31
+ # docker build -t bobi:dev . # source mode
32
+ # docker build --build-arg BOBI_BUILD=pypi \
33
+ # --build-arg BOBI_VERSION=0.22.0 -t bobi:dev . # pypi mode
34
+ # docker build --build-arg BOBI_BUILD=wheel -t bobi:dev . # wheel (dist/*.whl)
35
+
36
+ # Which builder produces /opt/venv: `source`, `pypi`, or `wheel`. bobi deploy
37
+ # passes `pypi` (+ BOBI_VERSION) in binary mode; the release pipeline passes
38
+ # `wheel` (with the prebuilt artifact in dist/); a plain `docker build` defaults to
39
+ # `source` so the repo's own CI keeps building from the branch.
40
+ ARG BOBI_BUILD=source
41
+
42
+ #####################################################################
43
+ # Builder base — build tools live here only; runtime never sees them.#
44
+ #####################################################################
45
+ FROM python:3.11-slim AS builder-base
46
+ # apsw / native deps may need a compiler if no manylinux wheel is published.
47
+ RUN apt-get update \
48
+ && apt-get install -y --no-install-recommends build-essential \
49
+ && rm -rf /var/lib/apt/lists/*
50
+
51
+ #####################################################################
52
+ # builder-source — build the wheel FROM the repo (dev + repo CI). #
53
+ #####################################################################
54
+ FROM builder-base AS builder-source
55
+ RUN pip install --no-cache-dir build
56
+ WORKDIR /src
57
+ # --- deps layer (cached on pyproject only) -------------------------#
58
+ # Install the project's runtime deps + the lean kb deps into the venv,
59
+ # keyed on pyproject.toml alone: an ordinary code edit doesn't touch it, so
60
+ # this (network-heavy) layer stays cached and only the thin wheel layer below
61
+ # rebuilds. Read the dep list straight from [project.dependencies] (stdlib
62
+ # tomllib) so it never drifts from pyproject. Install fastembed/sqlite-vec
63
+ # EXPLICITLY — never the `[kb]` extra, which on some releases stale-lists
64
+ # sentence-transformers → torch + ~2 GB CUDA the CPU instance never uses.
65
+ COPY pyproject.toml ./
66
+ RUN python -m venv /opt/venv \
67
+ && python -c "import tomllib; print(chr(10).join(tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']))" > /tmp/reqs.txt \
68
+ && /opt/venv/bin/pip install --no-cache-dir -r /tmp/reqs.txt "fastembed>=0.4" "sqlite-vec>=0.1.6"
69
+ # --- wheel layer (thin; rebuilds on any code change) ---------------#
70
+ # pyproject builds the wheel FROM the sdist, so the sdist must carry the
71
+ # force-included templates + event-server (it does — see pyproject sdist include).
72
+ # --no-deps: deps are already in the venv above, so this is just bobi.
73
+ COPY . .
74
+ RUN python -m build --wheel --outdir /dist \
75
+ && /opt/venv/bin/pip install --no-cache-dir --no-deps /dist/*.whl
76
+
77
+ #####################################################################
78
+ # builder-pypi — install a published bobi (binary-mode deploy).#
79
+ #####################################################################
80
+ FROM builder-base AS builder-pypi
81
+ # Pinned to the operator's CLI so the instance runs the same code as the binary
82
+ # that deployed it.
83
+ ARG BOBI_VERSION
84
+ # Install the kb deps the code actually uses (fastembed — the lightweight ONNX
85
+ # embedder — and sqlite-vec) EXPLICITLY, not via the `[kb]` extra: some published
86
+ # releases stale-list `sentence-transformers` in `[kb]`, dragging in torch + ~2 GB
87
+ # of CUDA wheels the dark CPU instance never uses (and that can blow the build
88
+ # timeout). Keep in sync with pyproject's `[project.optional-dependencies].kb`.
89
+ RUN python -m venv /opt/venv \
90
+ && /opt/venv/bin/pip install --no-cache-dir \
91
+ "bobi==${BOBI_VERSION}" "fastembed>=0.4" "sqlite-vec>=0.1.6"
92
+
93
+ #####################################################################
94
+ # builder-wheel — install a PREBUILT local wheel (release pipeline). #
95
+ #####################################################################
96
+ # The release pipeline builds the wheel ONCE in CI and stages it into the build
97
+ # context at dist/ (re-included in .dockerignore). We install THAT exact artifact
98
+ # here, so the canary smokes — and the fleet runs — the same bytes we publish to
99
+ # PyPI (not a separately source-rebuilt wheel). Deps come from the same
100
+ # pyproject-keyed layer as builder-source (cached across code-only changes); the
101
+ # wheel goes in --no-deps on top.
102
+ FROM builder-base AS builder-wheel
103
+ WORKDIR /src
104
+ COPY pyproject.toml ./
105
+ RUN python -m venv /opt/venv \
106
+ && python -c "import tomllib; print(chr(10).join(tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']))" > /tmp/reqs.txt \
107
+ && /opt/venv/bin/pip install --no-cache-dir -r /tmp/reqs.txt "fastembed>=0.4" "sqlite-vec>=0.1.6"
108
+ # The prebuilt wheel staged by the release pipeline (exactly one *.whl in dist/).
109
+ COPY dist/ /dist/
110
+ RUN /opt/venv/bin/pip install --no-cache-dir --no-deps /dist/*.whl
111
+
112
+ # Select the builder. With BOBI_BUILD=pypi, builder-source isn't in the graph
113
+ # (its `COPY . .` never runs), so the tiny binary context needs no source tree.
114
+ # BOBI_BUILD=wheel (the release pipeline) installs the prebuilt dist/ wheel.
115
+ FROM builder-${BOBI_BUILD} AS builder
116
+
117
+ #####################################################################
118
+ # model-baker — pre-download the fastembed model. Keyed ONLY on the #
119
+ # pinned fastembed version, so this (the slowest layer — a multi- #
120
+ # minute model download) stays cached across every code/framework #
121
+ # change. Runtime COPYs the baked model in BELOW the volatile venv, #
122
+ # so a code-only rebuild never re-bakes it. (Install fastembed alone, #
123
+ # never `[kb]` — see builder-source for the torch-bloat rationale.) #
124
+ #####################################################################
125
+ FROM python:3.11-slim AS model-baker
126
+ ENV HF_HOME=/opt/bobi/models
127
+ RUN pip install --no-cache-dir "fastembed>=0.4" \
128
+ && python -c "from fastembed import TextEmbedding; TextEmbedding(model_name='sentence-transformers/all-MiniLM-L6-v2')" \
129
+ && chmod -R a+rX /opt/bobi/models
130
+
131
+ #####################################################################
132
+ # Runtime — slim image, no build tools, no Node. (Shared by both.) #
133
+ #####################################################################
134
+ FROM python:3.11-slim AS runtime
135
+
136
+ # Channel or exact version for the native `claude` installer. Default to the
137
+ # `stable` channel (one week behind latest, skips major regressions); pass an
138
+ # exact version (e.g. 2.1.89) for fully reproducible production builds.
139
+ ARG CLAUDE_VERSION=stable
140
+ ARG BOBI_UID=10001
141
+ # Pinned aichat (the general-purpose LLM gateway CLI). Bump deliberately via
142
+ # `gh api repos/sigoden/aichat/releases/latest --jq .tag_name`.
143
+ ARG AICHAT_VERSION=0.30.0
144
+
145
+ ENV PYTHONUNBUFFERED=1 \
146
+ PIP_NO_CACHE_DIR=1 \
147
+ PATH="/opt/venv/bin:/home/bobi/.local/bin:${PATH}" \
148
+ HF_HOME=/opt/bobi/models \
149
+ DISABLE_AUTOUPDATER=1 \
150
+ HOME=/home/bobi \
151
+ DATA_DIR=/data \
152
+ BOBI_HOME=/data/.bobi
153
+ # NB: HOME is the IMAGE home (above), NOT the volume — baked team tools
154
+ # (~/.claude/skills, ~/dev/gstack) are read in place. Claude's durable state
155
+ # (creds + transcripts) is redirected to the volume at runtime via
156
+ # CLAUDE_CONFIG_DIR, which the entrypoint sets (NOT a build ENV — build steps
157
+ # must write skills to the image ~/.claude, so they `env -u CLAUDE_CONFIG_DIR`).
158
+
159
+ # Runtime packages only:
160
+ # curl, ca-certificates — fetch the claude installer; TLS
161
+ # gosu — drop privileges from root setup to the bobi user
162
+ # git — agents clone/operate on repos
163
+ # NB: no tini. Fly Machines (the deploy target) inject their own PID-1 init that
164
+ # reaps zombies + forwards signals, and layering tini on top is a documented
165
+ # cause of "failed to spawn ... No such file or directory" boot failures there.
166
+ # For other container runtimes, run with an init (e.g. `docker run --init`).
167
+ RUN apt-get update \
168
+ && apt-get install -y --no-install-recommends \
169
+ curl ca-certificates gosu git \
170
+ && rm -rf /var/lib/apt/lists/*
171
+
172
+ # Non-root runtime user (see header: bypassPermissions-as-root guard).
173
+ RUN useradd --create-home --uid ${BOBI_UID} --shell /bin/bash bobi
174
+
175
+ # Layers are ordered stable → volatile so a code-only rebuild touches only the
176
+ # last (cheap) layers — the model download and `claude` fetch stay cached. See
177
+ # docs/design/CUSTOM_AGENT_DEPS.md §"three clocks" for the ordering rationale.
178
+
179
+ # --- stable layers (cached across code/framework changes) ----------#
180
+ # Pinned native `claude` CLI (no Node) installed as the bobi user so it
181
+ # lands in ~/.local/bin (on PATH above). Cache key is CLAUDE_VERSION alone.
182
+ USER bobi
183
+ RUN curl -fsSL https://claude.ai/install.sh | bash -s -- "${CLAUDE_VERSION}" \
184
+ && /home/bobi/.local/bin/claude --version
185
+ USER root
186
+
187
+ # General-purpose LLM gateway CLI (aichat): lets any agent call out to other
188
+ # models over OpenAI-compatible endpoints — a one-shot model call, NOT agent
189
+ # delegation (that's codex). Baked into the base image for every team, like
190
+ # git/claude; the image stays generic — provider/model/key come from the team's
191
+ # `gateway` connection + env at runtime, never from this layer. Pinned,
192
+ # arch-detected static musl binary into /usr/local/bin (system-wide, on PATH).
193
+ # Cache key is AICHAT_VERSION alone, so a code-only rebuild never re-fetches it.
194
+ RUN arch="$(dpkg --print-architecture)" \
195
+ && case "$arch" in \
196
+ amd64) target=x86_64-unknown-linux-musl ;; \
197
+ arm64) target=aarch64-unknown-linux-musl ;; \
198
+ *) echo "aichat: unsupported arch $arch" >&2; exit 1 ;; \
199
+ esac \
200
+ && curl -fsSL "https://github.com/sigoden/aichat/releases/download/v${AICHAT_VERSION}/aichat-v${AICHAT_VERSION}-${target}.tar.gz" \
201
+ | tar -xz -C /usr/local/bin aichat \
202
+ && chmod +x /usr/local/bin/aichat \
203
+ && aichat --version
204
+
205
+ # Baked fastembed embedding model (cold-start speed; immutable). HF_HOME points
206
+ # here at both build and run, so it's a cache hit at runtime. Copied from
207
+ # model-baker, whose only cache key is the fastembed version — so an ordinary
208
+ # code change never re-downloads the model.
209
+ COPY --from=model-baker /opt/bobi/models /opt/bobi/models
210
+
211
+ # --- team-deps hook (C24 team-flavored images) ---------------------#
212
+ # A team's baked host tools (node, codex, gstack, …) as ONE stable layer,
213
+ # rendered from its `build:` spec by bobi/build_render.py and injected via
214
+ # the TEAM_DEPS build-arg. The default is a no-op, so a plain build / a no-deps
215
+ # team is byte-identical to the generic image. Positioned as the LAST stable
216
+ # layer — just BELOW the volatile venv COPY — so a code-only framework release
217
+ # rebuilds only the wheel, never re-runs the team's apt/npm/run. The hook runs
218
+ # as root (USER is root here); it `gosu`s to bobi to bake ~-relative tools
219
+ # into the image HOME (/home/bobi/.claude/skills, ~/dev/gstack), which the
220
+ # agent reads in place at runtime — the entrypoint redirects only Claude's
221
+ # durable state to the volume (CLAUDE_CONFIG_DIR) and symlinks skills back.
222
+ # See docs/design/CUSTOM_AGENT_DEPS.md §"three clocks".
223
+ ARG TEAM_DEPS=docker/noop-deps.sh
224
+ COPY ${TEAM_DEPS} /tmp/team-deps.sh
225
+ RUN bash /tmp/team-deps.sh && rm -f /tmp/team-deps.sh
226
+
227
+ # --- volatile layer (rebuilds on any framework/code change) --------#
228
+ # The prebuilt venv (bobi + deps) is the LAST heavy layer, so a code-only
229
+ # rebuild is just this copy plus the thin layers below — seconds, not minutes.
230
+ # Root-owned, world-readable.
231
+ COPY --from=builder /opt/venv /opt/venv
232
+ # Codex tool shells sanitize PATH and drop /opt/venv/bin. Surface the
233
+ # framework CLI in both user-local and system bins so agents can call
234
+ # `bobi` by bare name from Codex's current shell.
235
+ RUN mkdir -p /home/bobi/.local/bin \
236
+ && printf '%s\n' '#!/bin/sh' 'exec /opt/venv/bin/bobi "$@"' \
237
+ > /usr/local/bin/bobi \
238
+ && cp /usr/local/bin/bobi /home/bobi/.local/bin/bobi \
239
+ && chown bobi:bobi /home/bobi/.local/bin/bobi \
240
+ && chmod +x /usr/local/bin/bobi /home/bobi/.local/bin/bobi
241
+
242
+ COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
243
+ COPY docker/healthcheck.sh /usr/local/bin/healthcheck.sh
244
+ RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/healthcheck.sh
245
+
246
+ # Persistent state lives on this volume. Bobi runtime state defaults to
247
+ # /data/.bobi; Claude/Codex durable auth state uses /data/claude and /data/codex.
248
+ VOLUME ["/data"]
249
+ # WORKDIR must NOT be under /data: a volume mounted there shadows the
250
+ # build-time dir, so the container's cwd ceases to exist at runtime and the
251
+ # platform init (e.g. Fly Machines) fails to spawn the entrypoint with ENOENT.
252
+ # The entrypoint binds BOBI_ROOT to the selected agent run directory.
253
+ WORKDIR /
254
+
255
+ # Liveness: read the manager's health port from the volume and probe /health.
256
+ # start-period is generous — first boot installs a team and warms a session.
257
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
258
+ CMD ["/usr/local/bin/healthcheck.sh"]
259
+
260
+ # The entrypoint is PID 1 (under Fly's injected init): it does root-only volume
261
+ # setup, then `exec gosu`s to the bobi user running the selected agent under
262
+ # `bobi supervise`, so SIGTERM reaches the manager for graceful shutdown.
263
+ ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bobi container entrypoint (containerized-8 / #338).
4
+ #
5
+ # Runs as root (PID 1, under Fly's injected init — no tini; see Dockerfile) only
6
+ # long enough to prepare the mounted volume, then drops to the non-root
7
+ # `bobi` user and exec's the manager. Because the final step is
8
+ # `exec gosu ... bobi supervise -- --foreground`, SIGTERM is forwarded to the
9
+ # selected agent manager, which shuts sessions down gracefully (C2).
10
+ #
11
+ # First-boot install (empty volume -> `bobi agents install`) lives here for now so
12
+ # the image is independently testable; #339 (C9) hardens the idempotency and
13
+ # edge cases of this path.
14
+ set -euo pipefail
15
+
16
+ APP_USER="bobi"
17
+ DATA_DIR="${DATA_DIR:-/data}"
18
+ log() { echo "[entrypoint] $*"; }
19
+ fatal() { echo "[entrypoint] FATAL: $*" >&2; exit 1; }
20
+ # $HOME stays on the IMAGE (/home/bobi) — that's where baked team tools
21
+ # (~/.claude/skills, ~/dev/gstack) live, read in place, never copied. Only
22
+ # Claude's DURABLE state (credentials + transcripts + session history) is
23
+ # redirected onto the volume via CLAUDE_CONFIG_DIR, the supported override.
24
+ # Splitting the two this way (vs. seeding tools onto a volume HOME) keeps build
25
+ # HOME == runtime HOME, so the image's `verify: requires` proves the live paths.
26
+ export HOME="${HOME:-/home/bobi}"
27
+ [ -n "${BOBI_HOME:-}" ] || fatal "BOBI_HOME is required. The image sets BOBI_HOME=/data/.bobi."
28
+ export BOBI_HOME
29
+ export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${DATA_DIR}/claude}"
30
+
31
+ if [ -n "${BOBI_AGENT:-}" ]; then
32
+ AGENT_NAME="${BOBI_AGENT}"
33
+ elif [ -n "${BOBI_INSTANCE:-}" ]; then
34
+ AGENT_NAME="${BOBI_INSTANCE}"
35
+ elif [ -n "${BOBI_ROOT:-}" ] && [ "$(basename "${BOBI_ROOT}")" = "run" ]; then
36
+ AGENT_NAME="$(basename "$(dirname "${BOBI_ROOT}")")"
37
+ else
38
+ fatal "No Bobi Agent selected. Set BOBI_INSTANCE, BOBI_AGENT, or BOBI_ROOT."
39
+ fi
40
+ RUN_ROOT="${BOBI_ROOT:-${BOBI_HOME}/agents/${AGENT_NAME}/run}"
41
+ export BOBI_ROOT="${RUN_ROOT}"
42
+ PACKAGE_DIR="${RUN_ROOT}/package"
43
+
44
+ # The agent brain (#485). Decides the provider API key (api_key/subscription),
45
+ # the durable OAuth credential dir on the volume, and the credential file the
46
+ # first-boot subscription bootstrap waits for. Default claude for entrypoint
47
+ # branching only; do not export that default because agent.yaml must still be
48
+ # able to select a non-Claude brain when BOBI_BRAIN was not explicit.
49
+ ENTRYPOINT_BRAIN="${BOBI_BRAIN:-claude}"
50
+
51
+ configure_brain_paths() {
52
+ case "$ENTRYPOINT_BRAIN" in
53
+ codex)
54
+ BRAIN_SHADOW_KEY="OPENAI_API_KEY"
55
+ BRAIN_CRED_DIR="${DATA_DIR}/codex" # ~/.codex symlinks here
56
+ BRAIN_HOME_LINK="${HOME}/.codex"
57
+ BRAIN_CRED_FILE="auth.json"
58
+ ;;
59
+ *) # claude (default): durable state already lives under CLAUDE_CONFIG_DIR
60
+ BRAIN_SHADOW_KEY="ANTHROPIC_API_KEY"
61
+ BRAIN_CRED_DIR="${CLAUDE_CONFIG_DIR}"
62
+ BRAIN_HOME_LINK="${HOME}/.claude"
63
+ BRAIN_CRED_FILE=".credentials.json"
64
+ ;;
65
+ esac
66
+ }
67
+
68
+ validate_auth_mode() {
69
+ # The provider API key is brain-specific (ANTHROPIC_API_KEY / OPENAI_API_KEY);
70
+ # ${!BRAIN_SHADOW_KEY} is its live value via bash indirect expansion.
71
+ case "${BOBI_AUTH:-api_key}" in
72
+ api_key)
73
+ [ -n "${!BRAIN_SHADOW_KEY:-}" ] \
74
+ || fatal "BOBI_AUTH=api_key but ${BRAIN_SHADOW_KEY} is unset."
75
+ ;;
76
+ subscription)
77
+ # The provider API key silently outranks subscription OAuth creds and bills
78
+ # the API instead — it must be entirely absent in this mode (§6.1).
79
+ [ -z "${!BRAIN_SHADOW_KEY:-}" ] \
80
+ || fatal "BOBI_AUTH=subscription but ${BRAIN_SHADOW_KEY} is set; it overrides subscription auth. Unset it."
81
+ ;;
82
+ *)
83
+ fatal "unknown BOBI_AUTH='${BOBI_AUTH}' (expected api_key|subscription)."
84
+ ;;
85
+ esac
86
+ }
87
+
88
+ resolve_configured_brain() {
89
+ [ -z "${BOBI_BRAIN:-}" ] || return 0
90
+ [ -f "${PACKAGE_DIR}/agent.yaml" ] || return 0
91
+
92
+ local configured
93
+ if configured="$(BOBI_ROOT="${RUN_ROOT}" python - <<'PY' 2>/dev/null
94
+ import os
95
+ from pathlib import Path
96
+
97
+ from bobi.config import Config
98
+
99
+ print(Config.load(Path(os.environ["BOBI_ROOT"])).brain_kind or "", end="")
100
+ PY
101
+ )" && [ -n "${configured}" ]; then
102
+ ENTRYPOINT_BRAIN="${configured}"
103
+ fi
104
+ }
105
+
106
+ resolve_configured_brain
107
+ configure_brain_paths
108
+
109
+ materialize_codex_api_key_auth() {
110
+ local cred_dir="$1"
111
+ [ -n "${OPENAI_API_KEY:-}" ] || return 0
112
+ log "Writing Codex API-key auth file from OPENAI_API_KEY"
113
+ mkdir -p "${cred_dir}"
114
+ CODEX_CRED_DIR="${cred_dir}" OPENAI_API_KEY="${OPENAI_API_KEY}" python - <<'PY'
115
+ import json
116
+ import os
117
+ from pathlib import Path
118
+
119
+ path = Path(os.environ["CODEX_CRED_DIR"]) / "auth.json"
120
+ path.write_text(json.dumps({"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}) + "\n")
121
+ path.chmod(0o600)
122
+ PY
123
+ chown -R "${APP_USER}:${APP_USER}" "${cred_dir}"
124
+ }
125
+
126
+ codex_auth_uses_api_key() {
127
+ local cred_dir="$1"
128
+ CODEX_CRED_DIR="${cred_dir}" python - <<'PY'
129
+ import json
130
+ import os
131
+ import sys
132
+ from pathlib import Path
133
+
134
+ try:
135
+ data = json.loads((Path(os.environ["CODEX_CRED_DIR"]) / "auth.json").read_text())
136
+ except Exception:
137
+ sys.exit(1)
138
+ sys.exit(0 if isinstance(data, dict) and "OPENAI_API_KEY" in data else 1)
139
+ PY
140
+ }
141
+
142
+ AUTH_VALIDATED=0
143
+ if [ -n "${BOBI_BRAIN:-}" ] \
144
+ || [ -f "${PACKAGE_DIR}/agent.yaml" ] \
145
+ || { [ -z "${BOBI_TEAM_URL:-}" ] && [ -z "${BOBI_TEAM:-}" ]; }; then
146
+ validate_auth_mode
147
+ AUTH_VALIDATED=1
148
+ fi
149
+
150
+ # --- 1. Prepare the volume (root) -------------------------------------------
151
+ # Only durable state lives on the volume: BOBI_HOME, the selected run root, and
152
+ # Claude's config dir (CLAUDE_CONFIG_DIR). HOME is on the image and needs no
153
+ # volume prep.
154
+ mkdir -p "${BOBI_HOME}" "${RUN_ROOT}" "${RUN_ROOT}/workspace" "${CLAUDE_CONFIG_DIR}"
155
+
156
+ # Fly/EC2/k8s mount fresh volumes owned by root. Take ownership once so the
157
+ # non-root user can write; a stamp keeps subsequent boots from re-walking a
158
+ # large, already-correct tree.
159
+ if [ ! -e "${DATA_DIR}/.bobi-owned" ]; then
160
+ log "Taking ownership of ${DATA_DIR} for ${APP_USER} (first boot)"
161
+ chown -R "${APP_USER}:${APP_USER}" "${DATA_DIR}"
162
+ : > "${DATA_DIR}/.bobi-owned"
163
+ else
164
+ chown "${APP_USER}:${APP_USER}" "${DATA_DIR}" "${BOBI_HOME}" "${RUN_ROOT}" "${CLAUDE_CONFIG_DIR}"
165
+ fi
166
+
167
+ # --- 1b. Make ~/.claude coincide with the durable volume config dir (C24) -----
168
+ # $HOME stays on the image (baked tools read in place), but Claude's DURABLE
169
+ # state (creds, transcripts, settings) lives under CLAUDE_CONFIG_DIR on the
170
+ # volume. Point the whole ~/.claude AT that dir, so any tool/skill that hardcodes
171
+ # ~/.claude/{projects,settings.json,skills,…} sees Claude's real state — one
172
+ # coherent home tree, split underneath only by storage lifecycle (image vs
173
+ # volume), invisible to anything using `~`.
174
+ #
175
+ # Personal skills are baked OUTSIDE ~/.claude at /opt/bobi/skills (immutable
176
+ # image content; build-render.py put them there) and surfaced via the config
177
+ # dir's skills/ entry — a symlinked DIRECTORY is safe (files read inside it) and
178
+ # resolves to the exact path the build's `verify: requires` checked. No baked
179
+ # skills (generic image) → that link is skipped; Claude finds project skills.
180
+ if [ -d /opt/bobi/skills ]; then
181
+ log "Linking ${CLAUDE_CONFIG_DIR}/skills -> /opt/bobi/skills (baked team skills)"
182
+ ln -sfn /opt/bobi/skills "${CLAUDE_CONFIG_DIR}/skills"
183
+ fi
184
+ # Replace the image's real ~/.claude (created at build) with a symlink to the
185
+ # volume config dir. Idempotent: rewrite only when it isn't already that link
186
+ # (a fresh image rootfs each deploy ships the real dir; a same-machine restart
187
+ # already has the link). rm -rf only ever discards ephemeral image content —
188
+ # the durable state and the baked skills both live elsewhere.
189
+ if [ "$(readlink "${HOME}/.claude" 2>/dev/null)" != "${CLAUDE_CONFIG_DIR}" ]; then
190
+ log "Pointing ${HOME}/.claude -> ${CLAUDE_CONFIG_DIR} (durable config on volume)"
191
+ rm -rf "${HOME}/.claude"
192
+ ln -s "${CLAUDE_CONFIG_DIR}" "${HOME}/.claude"
193
+ fi
194
+
195
+ cd "${RUN_ROOT}"
196
+
197
+ # Carry HOME (the image home), BOBI_HOME/BOBI_ROOT (the durable selected Bobi
198
+ # runtime), and CLAUDE_CONFIG_DIR (the volume dir holding durable
199
+ # creds/transcripts) into every privilege drop.
200
+ as_app() {
201
+ if [ -n "${BOBI_BRAIN:-}" ] || [ "${ENTRYPOINT_BRAIN}" != "claude" ]; then
202
+ gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_BRAIN=${ENTRYPOINT_BRAIN}" "$@"
203
+ else
204
+ gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "$@"
205
+ fi
206
+ }
207
+
208
+ # --- 2. First boot: install a team if the volume has no agent (C9 hardens) ---
209
+ # Team source precedence: a public BOBI_TEAM_URL (fetched at boot — the
210
+ # dark instance reaches out, nothing reaches in) wins over BOBI_TEAM (a
211
+ # bundled/registry name).
212
+ #
213
+ # With NEITHER set on an empty volume the instance enters the "wait for team"
214
+ # state instead of crashing: it was provisioned blank for ssh-push delivery
215
+ # (`bobi deploy` with a local `team:` package — DEPLOY_INTERFACE.md). The
216
+ # operator pushes the team over `fly ssh` (sftp the tarball + `bobi agents install`),
217
+ # which lands run/package/agent.yaml on the volume; we poll for it, then start.
218
+ # This is the single-developer "I built it, ship it — no hosting" path, and it
219
+ # keeps PID 1 alive so the Fly machine stays "started" while we wait.
220
+ if [ ! -f "${PACKAGE_DIR}/agent.yaml" ]; then
221
+ if [ -n "${BOBI_TEAM_URL:-}" ]; then
222
+ log "First boot: installing team from URL ${BOBI_TEAM_URL} (non-interactive)"
223
+ as_app bobi agents install "${BOBI_TEAM_URL}" --name "${AGENT_NAME}" --non-interactive
224
+ elif [ -n "${BOBI_TEAM:-}" ]; then
225
+ log "First boot: installing team '${BOBI_TEAM}' (non-interactive)"
226
+ # BOBI_TEAM must resolve to something the INSTANCE can see: a path on the
227
+ # volume (e.g. an ssh-pushed /mnt/team) or a local package — there is NO team
228
+ # registry baked into the image, so a bare name won't resolve. Fail LOUD with
229
+ # the actionable alternative instead of letting `set -e` crash-loop the Fly
230
+ # machine on a bare pipefail trace (C9/#339).
231
+ if ! as_app bobi agents install "${BOBI_TEAM}" --name "${AGENT_NAME}" --non-interactive; then
232
+ log "ERROR: couldn't install team '${BOBI_TEAM}'. The container has no"
233
+ log " team registry, so BOBI_TEAM only resolves a path/package the"
234
+ log " instance can already see. To deliver a PUBLISHED team, set"
235
+ log " BOBI_TEAM_URL=<https .tar.gz> instead; to deliver a LOCAL"
236
+ log " package, use 'bobi deploy <name>' (ssh-push, no team source"
237
+ log " on the instance). See DEPLOYMENT.md / DEPLOY_INTERFACE.md."
238
+ exit 1
239
+ fi
240
+ else
241
+ log "No team source and empty volume — blank instance, waiting for an"
242
+ log "ssh-push team delivery (bobi deploy). Poll for ${PACKAGE_DIR}/agent.yaml..."
243
+ waited=0
244
+ while [ ! -f "${PACKAGE_DIR}/agent.yaml" ]; do
245
+ sleep 2
246
+ waited=$((waited + 2))
247
+ # Heartbeat every ~2 min so `fly logs` shows the instance is alive, not hung.
248
+ if [ $((waited % 120)) -eq 0 ]; then
249
+ log "Still waiting for a pushed team (${waited}s)..."
250
+ fi
251
+ done
252
+ log "Team appeared on the volume after ${waited}s — proceeding to start."
253
+ fi
254
+ fi
255
+
256
+ resolve_configured_brain
257
+ configure_brain_paths
258
+
259
+ # --- 3. Validate auth mode --------------------------------------------------
260
+ # First-boot team installs can define ``brain.kind`` in agent.yaml, so their
261
+ # shadow-key check may need to wait until after install. Blank/no-team boots and
262
+ # existing installs validate before the wait-for-team loop so auth mistakes fail
263
+ # fast instead of hanging as a blank instance.
264
+ if [ "${AUTH_VALIDATED}" != "1" ]; then
265
+ validate_auth_mode
266
+ fi
267
+
268
+ # --- 3b. Codex's durable OAuth dir on the volume (#485) ---------------------
269
+ # Same idea for codex: ~/.codex (where `codex login`/`codex exec` keep auth.json)
270
+ # points at a volume dir so the ChatGPT subscription survives a redeploy. claude
271
+ # already gets this via CLAUDE_CONFIG_DIR above; codex has no config-dir override,
272
+ # so we symlink the home dir directly.
273
+ if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
274
+ mkdir -p "${BRAIN_CRED_DIR}"
275
+ chown "${APP_USER}:${APP_USER}" "${BRAIN_CRED_DIR}"
276
+ if [ -d /opt/bobi/skills ]; then
277
+ log "Linking baked team skills into ${BRAIN_CRED_DIR}/skills for codex"
278
+ mkdir -p "${BRAIN_CRED_DIR}/skills"
279
+ chown "${APP_USER}:${APP_USER}" "${BRAIN_CRED_DIR}/skills"
280
+ for existing_skill in "${BRAIN_CRED_DIR}/skills"/*; do
281
+ [ -L "${existing_skill}" ] || continue
282
+ existing_target="$(readlink "${existing_skill}")"
283
+ case "${existing_target}" in
284
+ /opt/bobi/skills/*)
285
+ if [ ! -e "${existing_target}" ]; then
286
+ log "Removing stale baked codex skill link ${existing_skill}"
287
+ rm -f "${existing_skill}"
288
+ fi
289
+ ;;
290
+ esac
291
+ done
292
+ for skill_path in /opt/bobi/skills/*; do
293
+ [ -e "${skill_path}" ] || continue
294
+ skill_name="$(basename "${skill_path}")"
295
+ skill_dest="${BRAIN_CRED_DIR}/skills/${skill_name}"
296
+ if [ -e "${skill_dest}" ] || [ -L "${skill_dest}" ]; then
297
+ if [ -L "${skill_dest}" ]; then
298
+ skill_dest_target="$(readlink "${skill_dest}")"
299
+ case "${skill_dest_target}" in
300
+ /opt/bobi/skills/*) ;;
301
+ *)
302
+ log "Leaving existing codex skill link at ${skill_dest}; baked skill ${skill_name} not linked"
303
+ continue
304
+ ;;
305
+ esac
306
+ else
307
+ log "Leaving existing codex skill at ${skill_dest}; baked skill ${skill_name} not linked"
308
+ continue
309
+ fi
310
+ fi
311
+ ln -sfnT "${skill_path}" "${skill_dest}"
312
+ done
313
+ fi
314
+ if [ "$(readlink "${BRAIN_HOME_LINK}" 2>/dev/null)" != "${BRAIN_CRED_DIR}" ]; then
315
+ log "Pointing ${BRAIN_HOME_LINK} -> ${BRAIN_CRED_DIR} (durable codex creds on volume)"
316
+ rm -rf "${BRAIN_HOME_LINK}"
317
+ ln -s "${BRAIN_CRED_DIR}" "${BRAIN_HOME_LINK}"
318
+ fi
319
+ fi
320
+
321
+ # The Codex CLI also exists as an auxiliary tool for Claude-brained teams
322
+ # (`tool_library: [codex]`). Unlike Claude, Codex does not read OPENAI_API_KEY
323
+ # directly; it expects ~/.codex/auth.json. In subscription mode, never turn an
324
+ # ambient API key into Codex auth: subscription OAuth must remain authoritative.
325
+ if [ "${BOBI_AUTH:-api_key}" != "subscription" ]; then
326
+ if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
327
+ materialize_codex_api_key_auth "${BRAIN_CRED_DIR}"
328
+ else
329
+ materialize_codex_api_key_auth "${HOME}/.codex"
330
+ fi
331
+ elif [ -n "${OPENAI_API_KEY:-}" ]; then
332
+ log "Subscription mode: leaving OPENAI_API_KEY out of Codex auth materialization"
333
+ fi
334
+
335
+ if [ "${BOBI_AUTH:-api_key}" = "subscription" ]; then
336
+ if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
337
+ codex_dir="${BRAIN_CRED_DIR}"
338
+ else
339
+ codex_dir="${HOME}/.codex"
340
+ fi
341
+ if codex_auth_uses_api_key "${codex_dir}"; then
342
+ log "Subscription mode: removing Codex API-key auth file so OAuth can be used"
343
+ rm -f "${codex_dir}/auth.json"
344
+ fi
345
+ fi
346
+
347
+ # --- 4. Subscription auth: bootstrap login over Slack if no creds yet (C23) --
348
+ # Idempotent: a no-op once the credentials exist. They live under
349
+ # CLAUDE_CONFIG_DIR (the volume), not HOME — that's the durable state we keep.
350
+ if [ "${BOBI_AUTH:-api_key}" = "subscription" ] \
351
+ && [ ! -f "${BRAIN_CRED_DIR}/${BRAIN_CRED_FILE}" ]; then
352
+ log "Subscription mode, no ${ENTRYPOINT_BRAIN} credentials on volume — running login bootstrap"
353
+ as_app bobi agent "${AGENT_NAME}" login-bootstrap
354
+ fi
355
+
356
+ # --- 5. Hand off to the manager as the non-root user ------------------------
357
+ # Agent UI on by default IN THE CONTAINER (the manager starts it on the private
358
+ # 6PN; reach it with `bobi agent <name> ui <deployment>` / `fly proxy`). It's image
359
+ # behavior, not a per-instance flag, so existing instances pick it up on their
360
+ # next image swap. Disable with BOBI_UI=0 in the Fly env. The dark instance
361
+ # has no public route, so this exposes nothing — see DESIGN.md "Agent UI".
362
+ export BOBI_UI="${BOBI_UI:-1}"
363
+ log "Starting manager under self-heal watchdog (user=${APP_USER}, agent=${AGENT_NAME}, run=${RUN_ROOT}, home=${HOME}, bobi_home=${BOBI_HOME}, claude_config=${CLAUDE_CONFIG_DIR})"
364
+ # #464: launch the manager under `bobi supervise` instead of directly.
365
+ # The supervisor is the entrypoint process (parent); it spawns the manager as a
366
+ # child, watches the director's progress via the health endpoint, and restarts
367
+ # a wedged director from below — the one recovery layer stall-recovery cannot
368
+ # provide. It runs no agent loop, so it cannot wedge from the same cause; on
369
+ # restart-budget exhaustion it exits non-zero and Fly's machine restart policy
370
+ # escalates. `healthcheck.sh` is unaffected (the manager child still writes the
371
+ # port file). The forwarded `--foreground` keeps the manager a supervisable
372
+ # child rather than letting it daemonize.
373
+ if [ -n "${BOBI_BRAIN:-}" ] || [ "${ENTRYPOINT_BRAIN}" != "claude" ]; then
374
+ exec gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_BRAIN=${ENTRYPOINT_BRAIN}" "BOBI_UI=${BOBI_UI}" bobi supervise -- --foreground "$@"
375
+ else
376
+ exec gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_UI=${BOBI_UI}" bobi supervise -- --foreground "$@"
377
+ fi