froid-loop 0.11.1__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 (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/envvars.py ADDED
@@ -0,0 +1,111 @@
1
+ """Central registry of the ``FROID_LOOP_*`` runtime environment variables.
2
+
3
+ These operator/test override knobs used to be read inline at scattered call
4
+ sites (`engine`, `adapters.multiplexer`, `cli`, `process_host`), which left them
5
+ undiscoverable and undocumented. This module is the one place each is named,
6
+ typed, and given a reader; the operator-facing table lives in the README under
7
+ "Environment variables".
8
+
9
+ Each reader preserves its call site's exact semantics — same parse, same
10
+ fallback — so routing a site through here changes nothing observable. Only the
11
+ core operator/test knobs belong here (the count is deliberately not stated — it
12
+ has already changed once); the `FROID_LOOP_UNITY_*` / `FROID_LOOP_ENGINE_*` family
13
+ read by the bundled Unity plugin's stand-alone helper scripts is that plugin's
14
+ own contract (documented in the game-engine guide) and stays with it. Nor do the
15
+ session-protocol vars the engine *injects* into a child session
16
+ (`FROID_LOOP_RUN_DIR`, `FROID_LOOP_TASK_ID`, …): those have a producing side inside
17
+ the orchestrator, and the stdlib-only relays that read them back cannot import
18
+ this module at all.
19
+
20
+ Note what the carve-out is and is not: "has a producing side" does not qualify a
21
+ name, "is read only by a stdlib-only relay that cannot import this module" does.
22
+ Anything core orchestration reads back belongs here, and
23
+ `test_portability_guard.test_froid_loop_env_reads_only_in_the_registry` is the
24
+ enforced form of that rule.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ import os
31
+
32
+ #: Overrides the per-session wall-clock budget, in seconds (test / E2E hook).
33
+ SESSION_TIMEOUT_S = "FROID_LOOP_SESSION_TIMEOUT_S"
34
+ #: Forces the terminal-multiplexer backend by registered name.
35
+ MUX_BACKEND = "FROID_LOOP_MUX_BACKEND"
36
+ #: Forces the process-host implementation by registered name (test / override).
37
+ PROCESS_HOST = "FROID_LOOP_PROCESS_HOST"
38
+ #: Overrides the user-scoped state root that per-run control-plane state lives
39
+ #: under (see :func:`runs.state_root`), replacing the whole platform cascade.
40
+ STATE_DIR = "FROID_LOOP_STATE_DIR"
41
+
42
+
43
+ def session_timeout_s() -> float | None:
44
+ """The per-session wall-clock override in seconds, or ``None`` when unset.
45
+
46
+ Anything that is not a finite positive number of seconds reads as ``None``
47
+ (ignored), and the guard is deliberately two-sided. Rejecting zero and
48
+ negatives keeps a fat-fingered override from silently *shortening* a real
49
+ run's budget; rejecting non-finite values keeps ``inf`` / ``1e999`` /
50
+ ``Infinity`` — all of which ``float()`` accepts and all of which pass a bare
51
+ ``> 0`` — from silently *removing* it. That second half matters more than it
52
+ looks: both adapters build their monotonic and wall-clock deadlines by
53
+ adding this to the current time, so a non-finite budget yields a deadline
54
+ that can never expire, and this is the outer bound every stall-grace and
55
+ wake-nudge window defers to. Losing it means an unattended run can wedge
56
+ with no backstop left.
57
+
58
+ A very large *finite* value is still honoured. It is a duration, however
59
+ unwise, and an operator asking for one is expressing intent; ``inf`` is not
60
+ a duration at all.
61
+ """
62
+ raw = os.environ.get(SESSION_TIMEOUT_S)
63
+ if raw is None:
64
+ return None
65
+ try:
66
+ value = float(raw)
67
+ except ValueError:
68
+ return None
69
+ if not math.isfinite(value) or value <= 0:
70
+ return None
71
+ return value
72
+
73
+
74
+ def mux_backend() -> str | None:
75
+ """The forced terminal-multiplexer backend name, or ``None`` when unset.
76
+
77
+ Returned verbatim (callers test truthiness and resolve the name), so the
78
+ forced-selection semantics match the raw env read exactly.
79
+ """
80
+ return os.environ.get(MUX_BACKEND)
81
+
82
+
83
+ def process_host() -> str | None:
84
+ """The forced process-host name, or ``None`` when unset."""
85
+ return os.environ.get(PROCESS_HOST)
86
+
87
+
88
+ def state_dir() -> str | None:
89
+ """The overriding froid-loop state root, or ``None`` when unset.
90
+
91
+ Verbatim like the two name readers above — :func:`runs.state_root` uses the
92
+ value as the state root itself, so an operator who names a directory gets that
93
+ directory. Silently ignoring a stated override in favour of the platform
94
+ cascade would be the same failure :func:`mux_backend` refuses: a loud
95
+ misconfiguration turned into a quiet auto-select, discoverable only by
96
+ noticing where a run's events did *not* appear.
97
+
98
+ Reading verbatim is not the same as accepting anything: this reader reports
99
+ what is set, and :func:`runs.state_root` judges it. A **relative** value is
100
+ refused there rather than resolved, because the root is read by two processes
101
+ with different working directories — see that function for the full reason.
102
+ The split is deliberate; a reader that silently rewrote its variable would
103
+ make the refusal impossible to state.
104
+
105
+ The one value not passed through is the empty string, which reads as unset.
106
+ ``export FROID_LOOP_STATE_DIR=`` is what an unset-looking export leaves behind,
107
+ and it is not a directory an operator can have meant: ``Path("")`` is the
108
+ *current directory*, so honouring it would silently root the control plane at
109
+ whatever cwd the loop happened to be launched from.
110
+ """
111
+ return os.environ.get(STATE_DIR) or None
@@ -0,0 +1,225 @@
1
+ """Retry budgets and typed escalation.
2
+
3
+ CRITICAL escalations pause the run for a human; PREFERENCE escalations are
4
+ journaled and the run continues. Exhausted budgets plateau-defer: the story
5
+ is skipped and the run stays alive.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+ from typing import Any
13
+
14
+ from .adapters.base import SessionResult
15
+ from .model import StoryTask, VerifyOutcome
16
+ from .policy import Policy
17
+
18
+ SEVERITY_CRITICAL = "CRITICAL"
19
+ SEVERITY_PREFERENCE = "PREFERENCE"
20
+
21
+
22
+ class Action(StrEnum):
23
+ PROCEED = "proceed"
24
+ RETRY = "retry"
25
+ DEFER = "defer"
26
+ PAUSE = "pause"
27
+ # review.on_timeout = "salvage-if-done" only (#271): the engine attempts to
28
+ # commit the already-finalized dev product instead of burning another review
29
+ # cycle; when salvage is not applicable it falls back through
30
+ # `review_retry_or_exhaust`. Produced only by `decide_review_session`.
31
+ SALVAGE = "salvage"
32
+
33
+
34
+ # Timeout-like review verdicts review.on_timeout governs (#271): deliberately the
35
+ # same set `_post_kill_reconcile` treats as rescue-eligible. `crashed` is excluded
36
+ # — a hard window death is cheap to retry and already honors on-disk artifacts via
37
+ # the crash-path read-back — and env-fault (#194) short-circuits before this.
38
+ REVIEW_TIMEOUT_STATUSES = frozenset({"timeout", "stalled", "over_budget"})
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Decision:
43
+ action: Action
44
+ reason: str = ""
45
+
46
+
47
+ def critical_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]:
48
+ if not result_json:
49
+ return []
50
+ return [
51
+ e
52
+ for e in result_json.get("escalations", [])
53
+ if isinstance(e, dict) and str(e.get("severity", "")).upper() == SEVERITY_CRITICAL
54
+ ]
55
+
56
+
57
+ def preference_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]:
58
+ if not result_json:
59
+ return []
60
+ return [
61
+ e
62
+ for e in result_json.get("escalations", [])
63
+ if isinstance(e, dict) and str(e.get("severity", "")).upper() != SEVERITY_CRITICAL
64
+ ]
65
+
66
+
67
+ def env_fault_detail(result: SessionResult) -> str:
68
+ """The evidence excerpt for an environment-fault pause reason, or a generic
69
+ fallback when the adapter classified a fault but kept no line (#194). Shared
70
+ by every site that pauses a transport-failed session — dev/review here, plus
71
+ fix/workflow/sweep in the engine — so the reason wording stays uniform:
72
+ ``environment fault: <role> session <status> (<detail>)``."""
73
+ return result.env_fault_evidence or "transport-failure pattern in session log"
74
+
75
+
76
+ def env_fault_pause_reason(role: str, result: SessionResult) -> str:
77
+ """The uniform pause/escalation reason for a transport-failed session (#194).
78
+ `role` is the descriptor placed before "session" — e.g. "dev", "review",
79
+ "fix", "migration", "triage", or a richer "blocking workflow 'x' (y)". Keeps
80
+ the wording identical across escalation/engine/sweep (see env_fault_detail).
81
+
82
+ Composed over `session_failure_reason` so the two diagnoses cannot cancel
83
+ each other out (#489): `crashed` is in `ENV_FAULT_STATUSES`, and both deciders
84
+ test `env_fault` FIRST, so a session destroyed under the run whose pane-log
85
+ tail also matches a transport pattern would otherwise pause blaming only the
86
+ provider. Both facts hold and the operator needs both — a lost session is not
87
+ evidence about the API, and a log pattern is not evidence the session
88
+ survived."""
89
+ return f"environment fault: {session_failure_reason(role, result)} ({env_fault_detail(result)})"
90
+
91
+
92
+ def session_failure_reason(role: str, result: SessionResult) -> str:
93
+ """The reason text for a non-completed session: ``<role> session <status>``,
94
+ plus the lost-session diagnosis (#489).
95
+
96
+ Without the suffix a session destroyed under the run reads exactly like a CLI
97
+ that ran and produced nothing, and the operator debugs the agent instead of
98
+ the host. Routing is unchanged either way — the verdict was already correct,
99
+ only its explanation was missing.
100
+
101
+ Only a ``crashed`` verdict can ever carry the suffix (``session_vanished`` is
102
+ stamped nowhere else), so on the timeout/stall paths the suffix never appears.
103
+
104
+ The wording states what the evidence *withdraws*, not what it proves. All the
105
+ probe establishes is that a session lookup came back negative — see
106
+ ``TerminalMultiplexer.has_session``, whose False is "the backend did not
107
+ confirm it", not "the session provably no longer exists". That is enough to
108
+ stop an operator reading window death as a CLI exit, and not enough to assert
109
+ the session was destroyed."""
110
+ reason = f"{role} session {result.status}"
111
+ if result.session_vanished:
112
+ return (
113
+ f"{reason}: the multiplexer no longer reports the session, so the window's "
114
+ "disappearance is not evidence the CLI exited"
115
+ )
116
+ return reason
117
+
118
+
119
+ def decide_dev(
120
+ task: StoryTask,
121
+ result: SessionResult,
122
+ outcome: VerifyOutcome | None,
123
+ policy: Policy,
124
+ ) -> Decision:
125
+ """After a dev session (and its verification, when the session completed)."""
126
+ crits = critical_escalations(result.result_json)
127
+ if crits:
128
+ details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
129
+ return Decision(Action.PAUSE, f"CRITICAL escalation from dev session: {details}")
130
+
131
+ budget_left = task.attempt < policy.limits.max_dev_attempts
132
+ exhausted = _exhausted_action(task)
133
+
134
+ if result.status != "completed":
135
+ if result.env_fault:
136
+ # A transport/API failure (the CLI never reached the API, #194): the
137
+ # attempt did no real work, so pause for a human instead of charging
138
+ # it — re-arm resets the budget, exactly like a verify env-fault (rc
139
+ # 126/127). The crits check above already ran (env-fault results carry
140
+ # result_json=None, so it found nothing).
141
+ return Decision(
142
+ Action.PAUSE,
143
+ env_fault_pause_reason("dev", result),
144
+ )
145
+ reason = session_failure_reason("dev", result)
146
+ if budget_left:
147
+ return Decision(Action.RETRY, reason)
148
+ return Decision(exhausted, _exhaust_reason(task, reason))
149
+
150
+ assert outcome is not None
151
+ if outcome.ok:
152
+ return Decision(Action.PROCEED)
153
+ if outcome.severity == SEVERITY_CRITICAL:
154
+ return Decision(Action.PAUSE, outcome.reason)
155
+ if budget_left:
156
+ return Decision(Action.RETRY, outcome.reason)
157
+ return Decision(exhausted, _exhaust_reason(task, outcome.reason))
158
+
159
+
160
+ def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy) -> Decision:
161
+ """After a review session returns, before interpreting its done/followup status."""
162
+ crits = critical_escalations(result.result_json)
163
+ if crits:
164
+ details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
165
+ return Decision(Action.PAUSE, f"CRITICAL escalation from review session: {details}")
166
+
167
+ if result.status != "completed":
168
+ if result.env_fault:
169
+ # transport/API failure (#194): pause rather than charge a review
170
+ # cycle for a session that never reached the API (see decide_dev).
171
+ return Decision(
172
+ Action.PAUSE,
173
+ env_fault_pause_reason("review", result),
174
+ )
175
+ reason = session_failure_reason("review", result)
176
+ if result.status in REVIEW_TIMEOUT_STATUSES:
177
+ mode = policy.review.on_timeout
178
+ if mode == "defer":
179
+ return Decision(
180
+ _exhausted_action(task),
181
+ _exhaust_reason(task, f"{reason} (review.on_timeout=defer)"),
182
+ )
183
+ if mode == "salvage-if-done":
184
+ return Decision(Action.SALVAGE, reason)
185
+ return review_retry_or_exhaust(task, policy, reason)
186
+ return Decision(Action.PROCEED)
187
+
188
+
189
+ def review_retry_or_exhaust(task: StoryTask, policy: Policy, reason: str) -> Decision:
190
+ """The default routing for a failed review session: RETRY while the outer
191
+ cycle budget lasts, then plateau-defer (or re-escalate mid re-drive). Module-
192
+ level so the engine can fall back through it when a SALVAGE attempt turns out
193
+ not to be applicable (#271)."""
194
+ if task.review_cycle < policy.limits.max_review_cycles:
195
+ return Decision(Action.RETRY, reason)
196
+ return review_exhausted(task, reason)
197
+
198
+
199
+ def review_exhausted(task: StoryTask, reason: str) -> Decision:
200
+ """Terminal review-side failure routing without spending another session.
201
+
202
+ Used when a deterministic post-review repair has exhausted its own local
203
+ retry bound and launching another reviewer would be unsafe. It preserves the
204
+ same resolved-CRITICAL re-drive rule as ordinary review-budget exhaustion.
205
+ """
206
+ return Decision(_exhausted_action(task), _exhaust_reason(task, reason))
207
+
208
+
209
+ def _exhausted_action(task: StoryTask) -> Action:
210
+ """What a budget-exhausted, non-CRITICAL failure resolves to. Normally a
211
+ plateau-defer (skip the story, keep the run alive). But a story mid re-drive
212
+ of a human-resolved CRITICAL escalation (``resolved_redrive`` latched, not
213
+ yet re-committed) must NOT silently downgrade to a defer — that would file an
214
+ unresolved escalation as deferred work and roll back the human's correction.
215
+ Re-escalate so the human sees it again; ``_escalate`` preserves the tree."""
216
+ return Action.PAUSE if task.resolved_redrive else Action.DEFER
217
+
218
+
219
+ def _exhaust_reason(task: StoryTask, reason: str) -> str:
220
+ if task.resolved_redrive:
221
+ return (
222
+ "resolved-escalation re-drive did not converge — re-escalating "
223
+ f"instead of deferring: {reason}"
224
+ )
225
+ return reason
froid_loop/events.py ADDED
@@ -0,0 +1,266 @@
1
+ """Importable twin of the hardened event write in ``data/froid_loop_hook.py``.
2
+
3
+ The hook script is COPIED into every target project and runs inside the coding
4
+ CLI's process under whatever interpreter the host has, so it is stdlib-only by
5
+ contract (its docstring says so) and cannot import ``froid_loop`` to reach this
6
+ module — and this module cannot import it back, since it ships as package DATA
7
+ rather than as an importable module. Hence a twin rather than shared code:
8
+ ``_LINK_REPARSE_TAGS``, ``_first_workspace``, ``_is_link_like``, ``_write_all``
9
+ and ``_write_event`` below are byte-identical copies of the hook's, pinned that
10
+ way by ``tests/test_events.py::test_the_twinned_source_is_identical`` — which
11
+ AST-extracts both sides and compares the source segments, so a fix applied to one
12
+ writer of the events control plane and not the other cannot pass review silently.
13
+
14
+ Because they are byte-identical, their docstrings and comments are written from
15
+ the hook script's vantage point ("this relay runs under whatever interpreter the
16
+ host has") and stay that way on purpose: rewording either side to suit its own
17
+ file breaks parity, and diverging is exactly what two separately-hardened writers
18
+ of one control plane must not do. What the shared text says about the attack, the
19
+ platform branches, and the residual Windows windows holds for both.
20
+
21
+ The rest of the module is this side's own: the payload shaping the hook does
22
+ inline in its ``main()``, and :func:`relay`, which backs ``froid-loop relay
23
+ <Event>`` — the #461 Phase 2 hook target, an installed console script instead of
24
+ a file path inside the workspace that a branch switch can take away.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import stat
32
+ import time
33
+ from typing import IO, Any
34
+
35
+ # Windows reparse tags that make a directory entry REDIRECT somewhere else,
36
+ # compared against os.lstat().st_reparse_tag (Windows, 3.8+). Deliberately not
37
+ # os.path.isjunction(), which is 3.12+ — this relay runs under whatever
38
+ # interpreter the host has, not under the orchestrator's. Deliberately not "any
39
+ # reparse tag" either: cloud placeholders (OneDrive) and dedup stubs are reparse
40
+ # points too, and refusing those would stall a legitimate run. Empty on POSIX.
41
+ _LINK_REPARSE_TAGS = tuple(
42
+ tag
43
+ for tag in (
44
+ getattr(stat, "IO_REPARSE_TAG_SYMLINK", None),
45
+ getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None),
46
+ )
47
+ if tag is not None
48
+ )
49
+
50
+
51
+ def _first_workspace(payload):
52
+ paths = payload.get("workspacePaths")
53
+ if isinstance(paths, list) and paths and isinstance(paths[0], str):
54
+ return paths[0]
55
+ return None
56
+
57
+
58
+ def _is_link_like(path):
59
+ """True when `path` redirects elsewhere: a POSIX symlink, or a Windows
60
+ symlink OR DIRECTORY JUNCTION.
61
+
62
+ `os.path.islink()` is False for a junction — junctions are a distinct
63
+ reparse kind, which is why `os.path.isjunction()` exists at all. On Windows
64
+ the junction is the arm that matters: `mklink /J` needs no elevation, while
65
+ a directory symlink needs SeCreateSymbolicLinkPrivilege or Developer Mode —
66
+ so the unprivileged attack is exactly the one `islink()` misses.
67
+ """
68
+ if os.path.islink(path):
69
+ return True
70
+ try:
71
+ return getattr(os.lstat(path), "st_reparse_tag", 0) in _LINK_REPARSE_TAGS
72
+ except OSError:
73
+ return False
74
+
75
+
76
+ def _write_all(fd, data):
77
+ """Write every byte of `data` to `fd`.
78
+
79
+ `os.write()` may write FEWER bytes than asked and simply return the count. A
80
+ truncated event file is not merely retried, it is lost: `SignalWatcher.poll`
81
+ adds a filename to its consumed set BEFORE parsing it (signals.py), so
82
+ malformed JSON is skipped and never re-read — the session's Stop signal is
83
+ gone for good and the run waits out `session_timeout_min`. The buffered
84
+ `open()` this replaced looped internally; the raw fd needed for
85
+ O_NOFOLLOW/dir_fd does not, so loop here.
86
+ """
87
+ view = memoryview(data)
88
+ while view:
89
+ written = os.write(fd, view)
90
+ if written <= 0: # not observed in practice; a spinning hook is worse
91
+ raise OSError("short write to the event file")
92
+ view = view[written:]
93
+
94
+
95
+ def _write_event(events_dir, name, event):
96
+ """Write one event file into `events_dir`, refusing to follow a redirect.
97
+
98
+ The events dir is the orchestrator's control plane. A driven session has
99
+ write access to the project, so it could plant `<run_dir>/events` as a
100
+ symlink (or, on Windows, a junction) and redirect — or swallow — the
101
+ completion signal, stalling the run to `session_timeout_min` instead of
102
+ completing. `os.makedirs(exist_ok=True)` `isdir()`-checks THROUGH such a
103
+ link, so the refusal has to come before it. That refusal works on every
104
+ platform.
105
+
106
+ Where the platform has them, the create+replace is anchored to a dir_fd
107
+ opened O_NOFOLLOW: every later operation goes through that fd, so a swap
108
+ after the check cannot reach the write. Windows has neither
109
+ O_NOFOLLOW/O_DIRECTORY nor a handle-relative open (`os.supports_dir_fd` is
110
+ empty — dir_fd is implemented with the POSIX `*at` calls), so its fallback
111
+ re-resolves the path and the check-to-write window stays open there. It is
112
+ NARROWED, not closed: the redirect check runs again after the payload is
113
+ written and before it is published, so a swap still in place is refused and
114
+ the temp file removed. Two windows stay open on that path (#494), both
115
+ measured: a swap-and-restore around the create is undetectable from stdlib
116
+ Python, and a swap landing after the second check leaves the path-based
117
+ publish unable to find the temp file it wrote — it either raises or renames
118
+ a file the attacker planted inside the attacker's own directory. Neither
119
+ redirects the payload, and both end where a refusal ends: no event, so the
120
+ run waits out session_timeout_min. That is the same outcome an attacker gets
121
+ for free by leaving a redirect in place, which is refused without any race —
122
+ winning the race buys no capability, which is why the residual is accepted
123
+ rather than chased into ctypes/NtCreateFile inside a stdlib-only relay.
124
+
125
+ Mode is 0o600 (narrowed from the umask-derived mode an ordinary `open()`
126
+ produced): only the operator running the loop reads these.
127
+
128
+ Raises OSError on any refusal or failure; the caller degrades to a no-op.
129
+ """
130
+ if _is_link_like(events_dir):
131
+ raise OSError(f"refusing to write events into a redirected directory: {events_dir}")
132
+ os.makedirs(events_dir, exist_ok=True)
133
+ data = json.dumps(event).encode("utf-8")
134
+ tmp = name + ".tmp"
135
+ o_nofollow = getattr(os, "O_NOFOLLOW", 0)
136
+ o_directory = getattr(os, "O_DIRECTORY", 0)
137
+ # O_BINARY is a no-op flag on POSIX; on Windows it stops the fd from
138
+ # newline-translating what os.write() puts through it.
139
+ create = os.O_WRONLY | os.O_CREAT | os.O_EXCL | o_nofollow | getattr(os, "O_BINARY", 0)
140
+ # Probe os.rename, not os.replace: CPython omits os.replace from
141
+ # supports_dir_fd on Linux even though it accepts src_dir_fd/dst_dir_fd, so
142
+ # probing it would leave this whole branch dead everywhere. This branch is
143
+ # POSIX-only by construction, and there rename(2) IS the atomic-replace
144
+ # primitive os.replace wraps — probe the function actually called.
145
+ if o_nofollow and o_directory and {os.open, os.rename} <= os.supports_dir_fd:
146
+ dir_fd = os.open(events_dir, os.O_RDONLY | o_directory | o_nofollow)
147
+ try:
148
+ fd = os.open(tmp, create, 0o600, dir_fd=dir_fd)
149
+ try:
150
+ _write_all(fd, data)
151
+ finally:
152
+ os.close(fd)
153
+ os.rename(tmp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
154
+ finally:
155
+ os.close(dir_fd)
156
+ return
157
+ # Fallback (Windows): no dir_fd to anchor to, so the create below re-resolves
158
+ # events_dir by path. A swap into a junction between the check above and this
159
+ # create would have put the temp file inside the attacker's directory. Check
160
+ # again before publishing, so a swap that is still in place is refused rather
161
+ # than followed — the realistic shape, since a junction has to persist to
162
+ # capture the events the attacker is after.
163
+ tmp_path = os.path.join(events_dir, tmp)
164
+ fd = os.open(tmp_path, create, 0o600)
165
+ try:
166
+ _write_all(fd, data)
167
+ finally:
168
+ os.close(fd)
169
+ if _is_link_like(events_dir):
170
+ try:
171
+ os.unlink(tmp_path)
172
+ except OSError:
173
+ pass
174
+ raise OSError(f"events directory was redirected mid-write: {events_dir}")
175
+ os.replace(tmp_path, os.path.join(events_dir, name))
176
+
177
+
178
+ # --------------------------------------------------------------- this side only
179
+
180
+
181
+ def event_file_name(ts: int, task_id: str, event_name: str) -> str:
182
+ """The event file's name. Sorted-by-time by construction (``ts`` first, fixed
183
+ width in practice), and carrying the task id so ``SignalWatcher`` can attribute
184
+ a file without opening it. Mirrors the hook's f-string exactly; the twin above
185
+ stops at the write, so this and :func:`shape_event` are pinned behaviorally
186
+ instead (``test_relay_and_hook_produce_the_same_event``)."""
187
+ return f"{ts}-{task_id}-{event_name}.json"
188
+
189
+
190
+ def shape_event(ts: int, event_name: str, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
191
+ """The event record the orchestrator consumes, built from one hook payload."""
192
+ return {
193
+ "ts": ts,
194
+ "event": event_name,
195
+ "task_id": task_id,
196
+ # Payload keys vary by CLI: snake_case (claude/codex), conversation_id
197
+ # (cursor), or camelCase (copilot's sessionId/transcriptPath, agy's
198
+ # conversationId). Try each.
199
+ "session_id": (
200
+ payload.get("session_id")
201
+ or payload.get("conversation_id")
202
+ or payload.get("sessionId")
203
+ or payload.get("conversationId")
204
+ ),
205
+ "transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"),
206
+ # agy sends no cwd — it sends workspacePaths, a list of workspace roots.
207
+ "cwd": payload.get("cwd") or _first_workspace(payload),
208
+ }
209
+
210
+
211
+ def _read_payload(stdin: IO[str]) -> dict[str, Any]:
212
+ """The hook payload, or an empty dict for anything unreadable.
213
+
214
+ A hook that fires with nothing on stdin, half a JSON document, undecodable
215
+ bytes, or a bare list is not an error the operator can act on — the event still
216
+ has to be written, because the run's completion signal rides on it. Every
217
+ non-dict outcome collapses to ``{}`` and the shaped event simply carries nulls.
218
+ ``UnicodeDecodeError`` and ``json.JSONDecodeError`` are both ``ValueError``
219
+ subclasses and ride the same arm; ``OSError`` covers a closed or unreadable
220
+ descriptor.
221
+ """
222
+ try:
223
+ payload = json.load(stdin)
224
+ except (ValueError, OSError):
225
+ return {}
226
+ return payload if isinstance(payload, dict) else {}
227
+
228
+
229
+ def relay(event_name: str, stdin: IO[str]) -> int:
230
+ """Write one event file for the session this process was spawned inside.
231
+
232
+ The contract is the hook script's, because the hook config points at one or
233
+ the other and the orchestrator must not be able to tell which ran: a silent
234
+ no-op when the session was not spawned by froid-loop (the env vars are the
235
+ detector), garbage stdin tolerated, and any ``OSError`` from a hostile or
236
+ broken events dir degrading to rc 0. Never anything on stdout — the CLI hosts
237
+ parse hook stdout — and never a non-zero rc, which several of them surface as
238
+ a failed tool call inside the very session whose completion this reports.
239
+
240
+ Returns 0 unconditionally. Nothing here is worth failing a session over: the
241
+ orchestrator's fallback for a missing event is ``session_timeout_min``, and an
242
+ attacker who can suppress the event can already get that outcome by planting
243
+ the redirect ``_write_event`` refuses.
244
+ """
245
+ run_dir = os.environ.get("FROID_LOOP_RUN_DIR")
246
+ task_id = os.environ.get("FROID_LOOP_TASK_ID")
247
+ if not run_dir or not task_id:
248
+ return 0
249
+ ts = time.time_ns()
250
+ event = shape_event(ts, event_name, task_id, _read_payload(stdin))
251
+ # $FROID_LOOP_EVENTS_DIR when the orchestrator names one (#494 moved the
252
+ # channel out of the project tree), else the legacy in-tree location — the
253
+ # same preference, spelled the same way, as the copied hook script's `main()`.
254
+ # `or`, not a presence test: an exported-but-empty value names the launch cwd.
255
+ # The no-op detector above stays RUN_DIR + TASK_ID for the reason the hook's
256
+ # docstring gives: an older orchestrator sets neither the new variable nor any
257
+ # expectation that this relay needs it, and its sessions must still complete.
258
+ events_dir = os.environ.get("FROID_LOOP_EVENTS_DIR") or os.path.join(run_dir, "events")
259
+ try:
260
+ _write_event(events_dir, event_file_name(ts, task_id, event_name), event)
261
+ except OSError:
262
+ # A hostile or broken events dir must degrade to the orchestrator's normal
263
+ # session_timeout_min path, never surface as a hook failure that fails the
264
+ # CLI window (mirrors the hook script's own write wrap).
265
+ return 0
266
+ return 0