freemicro 0.1.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.
- freemicro/__init__.py +18 -0
- freemicro/__main__.py +6 -0
- freemicro/agentkeys.py +590 -0
- freemicro/cli.py +2368 -0
- freemicro/compat.py +273 -0
- freemicro/config.py +107 -0
- freemicro/daemon.py +553 -0
- freemicro/default_keymap.json +365 -0
- freemicro/detector/__init__.py +5 -0
- freemicro/detector/probe.py +190 -0
- freemicro/device/__init__.py +192 -0
- freemicro/device/codex_micro.py +678 -0
- freemicro/device/lighting.py +347 -0
- freemicro/diagnostics.py +628 -0
- freemicro/focus.py +491 -0
- freemicro/hooks_install.py +317 -0
- freemicro/input/__init__.py +19 -0
- freemicro/input/actions.py +999 -0
- freemicro/input/bridge.py +1131 -0
- freemicro/input/keys.py +273 -0
- freemicro/input/latch.py +176 -0
- freemicro/input/pointer.py +501 -0
- freemicro/input/quartz.py +434 -0
- freemicro/lighting_owner.py +836 -0
- freemicro/menubar/__init__.py +34 -0
- freemicro/menubar/__main__.py +15 -0
- freemicro/menubar/app.py +670 -0
- freemicro/menubar/checks.py +234 -0
- freemicro/menubar/cocoa.py +381 -0
- freemicro/menubar/model.py +438 -0
- freemicro/menubar/status.py +560 -0
- freemicro/onboarding.py +513 -0
- freemicro/padconfig.py +1383 -0
- freemicro/permission_prompt.py +449 -0
- freemicro/permissions.py +212 -0
- freemicro/renderers/__init__.py +37 -0
- freemicro/renderers/base.py +157 -0
- freemicro/renderers/micro_leds.py +1045 -0
- freemicro/selftest.py +538 -0
- freemicro/staleness.py +1133 -0
- freemicro/state/__init__.py +43 -0
- freemicro/state/engine.py +1374 -0
- freemicro/state/hooks.py +256 -0
- freemicro/state/slots.py +91 -0
- freemicro/uninstall.py +926 -0
- freemicro/webui/__init__.py +40 -0
- freemicro/webui/api.py +479 -0
- freemicro/webui/apps.py +102 -0
- freemicro/webui/configio.py +529 -0
- freemicro/webui/keycaps.py +375 -0
- freemicro/webui/layout.py +401 -0
- freemicro/webui/layouts.py +209 -0
- freemicro/webui/padlink.py +664 -0
- freemicro/webui/server.py +421 -0
- freemicro/webui/starters.py +420 -0
- freemicro/webui/static/app.css +1041 -0
- freemicro/webui/static/app.js +3168 -0
- freemicro/webui/static/index.html +101 -0
- freemicro-0.1.0.dist-info/METADATA +566 -0
- freemicro-0.1.0.dist-info/RECORD +63 -0
- freemicro-0.1.0.dist-info/WHEEL +4 -0
- freemicro-0.1.0.dist-info/entry_points.txt +2 -0
- freemicro-0.1.0.dist-info/licenses/LICENSE +21 -0
freemicro/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""FreeMicro - turn a macro pad into a live status light for coding agents.
|
|
2
|
+
|
|
3
|
+
Four decoupled layers:
|
|
4
|
+
|
|
5
|
+
* :mod:`freemicro.state` - Claude Code hooks → normalized agent state.
|
|
6
|
+
* :mod:`freemicro.renderers` - state → the pad's LEDs.
|
|
7
|
+
* :mod:`freemicro.detector` - read-only hardware capability probe.
|
|
8
|
+
* :mod:`freemicro.config` - user configuration.
|
|
9
|
+
|
|
10
|
+
The public surface is intentionally tiny; see the CLI (``freemicro --help``)
|
|
11
|
+
or the module docstrings for details.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from freemicro.state import AgentState, StateStore, classify
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["AgentState", "StateStore", "classify", "__version__"]
|
freemicro/__main__.py
ADDED
freemicro/agentkeys.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Which project each of the six Agent Keys stands for, and what colour it is.
|
|
2
|
+
|
|
3
|
+
The pad has six individually addressable Agent Keys. Until this module existed
|
|
4
|
+
FreeMicro lit all six with the single winning state - six copies of one status
|
|
5
|
+
light - which is the largest gap between what the hardware can do and what the
|
|
6
|
+
project did with it. On the vendor's pad each Agent Key follows one *chat*. This
|
|
7
|
+
is the equivalent for Claude Code.
|
|
8
|
+
|
|
9
|
+
What is a key bound to?
|
|
10
|
+
-----------------------
|
|
11
|
+
**The project directory, not the session.** This is the one design decision
|
|
12
|
+
everything else follows from.
|
|
13
|
+
|
|
14
|
+
A Claude Code "session" is a terminal process with a UUID that dies when you
|
|
15
|
+
close the tab; the next tab gets a new UUID. Binding ``AG00`` to a session id
|
|
16
|
+
would mean re-binding your pad several times a day, and a pad you have to keep
|
|
17
|
+
re-teaching is worse than no pad. A *project directory* is stable for as long as
|
|
18
|
+
the work is: it survives restarts, `/clear`, a crashed tab, a reboot. It is also
|
|
19
|
+
how people actually think - "the API repo is waiting on me", never "session
|
|
20
|
+
0f2c…9a is waiting on me".
|
|
21
|
+
|
|
22
|
+
Hook events already carry ``cwd`` (see :mod:`freemicro.state.hooks`), so this
|
|
23
|
+
needs no new plumbing. Several sessions in one directory collapse into one
|
|
24
|
+
project, and the project shows the highest-priority state among them - if any
|
|
25
|
+
tab in that repo needs you, the key for that repo says so.
|
|
26
|
+
|
|
27
|
+
The slot-stability rule
|
|
28
|
+
-----------------------
|
|
29
|
+
An indicator you cannot trust is noise. If the key that meant "the API repo"
|
|
30
|
+
silently became "the docs repo" because activity order shifted, you would have
|
|
31
|
+
to read the pad instead of glancing at it, and the feature would be worthless.
|
|
32
|
+
|
|
33
|
+
So slots are **sticky**, in this precedence:
|
|
34
|
+
|
|
35
|
+
1. **A pinned slot is absolute.** Under ``pinned``/``manual`` a configured slot
|
|
36
|
+
always represents that directory. If nothing is live there the key is dark -
|
|
37
|
+
it is *never* lent to another project. Your pins do not move.
|
|
38
|
+
2. **An incumbent keeps its slot.** A project that held slot *n* keeps slot *n*
|
|
39
|
+
for as long as it stays live, no matter how the activity order changes
|
|
40
|
+
around it. Nothing is ever evicted for being less recent.
|
|
41
|
+
3. **Only vacated slots are refilled.** When a project goes stale (the store's
|
|
42
|
+
TTL) or ends, its slot frees, and the most-recently-active project without a
|
|
43
|
+
slot moves in - into the lowest free index, so keys fill left to right.
|
|
44
|
+
4. **A free slot remembers its last occupant.** If a project goes away and its
|
|
45
|
+
slot has not been reused, coming back puts it on the same key.
|
|
46
|
+
5. **First run** (no history) fills slots 0-5 with live projects, most recent
|
|
47
|
+
first. That is factory ``recent`` behaviour, and it means the pad is useful
|
|
48
|
+
with an empty config.
|
|
49
|
+
|
|
50
|
+
The consequence worth stating plainly: with more than six live projects, the
|
|
51
|
+
seventh does **not** appear until a slot frees. Bumping an incumbent for
|
|
52
|
+
whoever moved most recently would make the pad unreadable - which is precisely
|
|
53
|
+
what rule 2 exists to prevent.
|
|
54
|
+
|
|
55
|
+
Purity
|
|
56
|
+
------
|
|
57
|
+
Everything here is a pure function of ``(config, sessions, previous, now)``. No
|
|
58
|
+
device, no filesystem, no clock of its own. :class:`SlotResolver` is the only
|
|
59
|
+
stateful thing, and all it holds is the previous assignment.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import os
|
|
65
|
+
import time
|
|
66
|
+
from collections import Counter
|
|
67
|
+
from dataclasses import dataclass, field
|
|
68
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
69
|
+
|
|
70
|
+
from freemicro.state.engine import (
|
|
71
|
+
DEFAULT_DECAY,
|
|
72
|
+
AgentState,
|
|
73
|
+
DecayPolicy,
|
|
74
|
+
SessionState,
|
|
75
|
+
decay_of,
|
|
76
|
+
)
|
|
77
|
+
from freemicro.state.engine import effective_state as _engine_effective_state
|
|
78
|
+
|
|
79
|
+
#: One slot per Agent Key, ``AG00``-``AG05``.
|
|
80
|
+
SLOT_COUNT = 6
|
|
81
|
+
|
|
82
|
+
#: The six most recently active projects fill the keys automatically. Zero
|
|
83
|
+
#: config, and what the factory pad does (``codex-micro-agent-source``).
|
|
84
|
+
POLICY_RECENT = "recent"
|
|
85
|
+
#: Pinned directories keep their key; the rest fill from ``recent``.
|
|
86
|
+
POLICY_PINNED = "pinned"
|
|
87
|
+
#: Exactly the pinned set. Unpinned keys stay dark.
|
|
88
|
+
POLICY_MANUAL = "manual"
|
|
89
|
+
#: The pre-slot behaviour: all six keys show the one winning state. Kept for
|
|
90
|
+
#: people who liked the old single-colour pad, and for a one-project machine
|
|
91
|
+
#: where six copies of one light is arguably the honest picture.
|
|
92
|
+
POLICY_MIRROR = "mirror"
|
|
93
|
+
|
|
94
|
+
POLICIES: Tuple[str, ...] = (
|
|
95
|
+
POLICY_RECENT,
|
|
96
|
+
POLICY_PINNED,
|
|
97
|
+
POLICY_MANUAL,
|
|
98
|
+
POLICY_MIRROR,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AgentKeysError(ValueError):
|
|
103
|
+
"""Raised for an ``agent_keys`` section we cannot make sense of."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Configuration
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def normalise_project(path: Any) -> str:
|
|
111
|
+
"""Canonical form of a project directory, for comparison and display.
|
|
112
|
+
|
|
113
|
+
Purely textual - ``expanduser`` plus ``normpath``, no ``realpath``. Touching
|
|
114
|
+
the filesystem here would make the resolver impure and would resolve
|
|
115
|
+
symlinks that the user's own ``cwd`` has not resolved, so two names for one
|
|
116
|
+
directory would stop matching rather than start.
|
|
117
|
+
"""
|
|
118
|
+
text = str(path or "").strip()
|
|
119
|
+
if not text:
|
|
120
|
+
return ""
|
|
121
|
+
text = os.path.expanduser(text)
|
|
122
|
+
text = os.path.normpath(text)
|
|
123
|
+
# normpath leaves a lone "/" alone but strips other trailing separators.
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class AgentKeysConfig:
|
|
129
|
+
"""The parsed ``agent_keys`` section.
|
|
130
|
+
|
|
131
|
+
``slots`` is always exactly :data:`SLOT_COUNT` long, one entry per Agent Key
|
|
132
|
+
in ``AG00``-``AG05`` order, each a normalised project path or ``""``.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
policy: str = POLICY_RECENT
|
|
136
|
+
slots: Tuple[str, ...] = ("",) * SLOT_COUNT
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
if len(self.slots) != SLOT_COUNT: # pragma: no cover - guarded in parse
|
|
140
|
+
raise AgentKeysError(f"agent_keys.slots must have {SLOT_COUNT} entries")
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def pins(self) -> Tuple[str, ...]:
|
|
144
|
+
"""The pinned paths, in slot order, ``""`` where unpinned.
|
|
145
|
+
|
|
146
|
+
Only ``pinned`` and ``manual`` honour pins; the other policies ignore
|
|
147
|
+
them so that switching policy back and forth never loses them.
|
|
148
|
+
"""
|
|
149
|
+
if self.policy in (POLICY_PINNED, POLICY_MANUAL):
|
|
150
|
+
return self.slots
|
|
151
|
+
return ("",) * SLOT_COUNT
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def mirrors(self) -> bool:
|
|
155
|
+
"""True when the six keys should all show the one winning state."""
|
|
156
|
+
return self.policy == POLICY_MIRROR
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
159
|
+
"""Round-trip form - the exact shape the web UI reads and writes."""
|
|
160
|
+
return {"policy": self.policy, "slots": list(self.slots)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_agent_keys(raw: Any) -> AgentKeysConfig:
|
|
164
|
+
"""Validate an ``agent_keys`` mapping. Raises :class:`AgentKeysError`.
|
|
165
|
+
|
|
166
|
+
Deliberately forgiving in one direction: a slot naming a directory that
|
|
167
|
+
does not exist (or is not running right now) is **fine**. Projects come and
|
|
168
|
+
go, and a config that refuses to load because you closed a terminal would
|
|
169
|
+
be intolerable.
|
|
170
|
+
"""
|
|
171
|
+
if raw is None:
|
|
172
|
+
return AgentKeysConfig()
|
|
173
|
+
if not isinstance(raw, Mapping):
|
|
174
|
+
raise AgentKeysError('"agent_keys" must be an object')
|
|
175
|
+
|
|
176
|
+
unknown = set(raw) - {"policy", "slots", "comment", "_comment"}
|
|
177
|
+
if unknown:
|
|
178
|
+
raise AgentKeysError(
|
|
179
|
+
"agent_keys has unknown field(s): " + ", ".join(sorted(unknown))
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
policy = str(raw.get("policy", POLICY_RECENT)).strip().lower()
|
|
183
|
+
if policy not in POLICIES:
|
|
184
|
+
raise AgentKeysError(
|
|
185
|
+
f"agent_keys.policy must be one of {', '.join(POLICIES)}; "
|
|
186
|
+
f"got {raw.get('policy')!r}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
slots_raw = raw.get("slots")
|
|
190
|
+
if slots_raw is None:
|
|
191
|
+
slots_raw = []
|
|
192
|
+
if isinstance(slots_raw, (str, bytes)) or not isinstance(slots_raw, Sequence):
|
|
193
|
+
raise AgentKeysError('"agent_keys.slots" must be a list of six entries')
|
|
194
|
+
if len(slots_raw) > SLOT_COUNT:
|
|
195
|
+
raise AgentKeysError(
|
|
196
|
+
f"agent_keys.slots has {len(slots_raw)} entries; the pad has "
|
|
197
|
+
f"{SLOT_COUNT} Agent Keys"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
slots: List[str] = []
|
|
201
|
+
for index, entry in enumerate(slots_raw):
|
|
202
|
+
if entry is None:
|
|
203
|
+
slots.append("")
|
|
204
|
+
continue
|
|
205
|
+
if not isinstance(entry, str):
|
|
206
|
+
raise AgentKeysError(
|
|
207
|
+
f"agent_keys.slots[{index}] must be a project path or null, "
|
|
208
|
+
f"got {type(entry).__name__}"
|
|
209
|
+
)
|
|
210
|
+
slots.append(normalise_project(entry))
|
|
211
|
+
slots.extend([""] * (SLOT_COUNT - len(slots)))
|
|
212
|
+
return AgentKeysConfig(policy=policy, slots=tuple(slots))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Projects
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def project_label(path: str) -> str:
|
|
220
|
+
"""The short name to show for a project directory."""
|
|
221
|
+
if not path:
|
|
222
|
+
return ""
|
|
223
|
+
base = os.path.basename(path.rstrip(os.sep)) or path
|
|
224
|
+
return base
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True)
|
|
228
|
+
class Project:
|
|
229
|
+
"""One project directory and the live sessions inside it."""
|
|
230
|
+
|
|
231
|
+
path: str
|
|
232
|
+
state: AgentState
|
|
233
|
+
last_active: float
|
|
234
|
+
sessions: Tuple[SessionState, ...] = ()
|
|
235
|
+
label: str = ""
|
|
236
|
+
|
|
237
|
+
def __post_init__(self) -> None:
|
|
238
|
+
if not self.label:
|
|
239
|
+
object.__setattr__(self, "label", project_label(self.path))
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def lead(self) -> Optional[SessionState]:
|
|
243
|
+
"""The session that decided this project's state.
|
|
244
|
+
|
|
245
|
+
This is the session an Agent Key press should jump to: the one that is
|
|
246
|
+
waiting on you, erroring, or most recently working.
|
|
247
|
+
"""
|
|
248
|
+
return self.sessions[0] if self.sessions else None
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def session_count(self) -> int:
|
|
252
|
+
return len(self.sessions)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def effective_state(
|
|
256
|
+
session: SessionState,
|
|
257
|
+
*,
|
|
258
|
+
now: float,
|
|
259
|
+
decay: DecayPolicy = DEFAULT_DECAY,
|
|
260
|
+
) -> AgentState:
|
|
261
|
+
"""A session's state after its expired claims are retired.
|
|
262
|
+
|
|
263
|
+
Two decays, one rule, and the rule itself lives in
|
|
264
|
+
:func:`freemicro.state.engine.effective_state` - the Agent Keys must reach
|
|
265
|
+
the same verdict as ``resolved_state()`` and ``freemicro status`` or the pad
|
|
266
|
+
ends up showing something no other view agrees with.
|
|
267
|
+
|
|
268
|
+
* The factory's green is ``unread``, not "completed": it clears once you
|
|
269
|
+
look at the thread (``docs/FACTORY-DEFAULTS.md`` §2). FreeMicro has no
|
|
270
|
+
signal for "the human looked at it", so the decay is a timer - applied
|
|
271
|
+
*per project* here, so one repo going quiet does not clear another's.
|
|
272
|
+
* Blue is a claim that has to keep being renewed. Interrupting an agent
|
|
273
|
+
fires no hook at all, so a ``working`` claim that has gone silent is
|
|
274
|
+
retired rather than believed for the next half hour.
|
|
275
|
+
"""
|
|
276
|
+
return _engine_effective_state(session, now=now, decay=decay)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def group_projects(
|
|
280
|
+
sessions: Sequence[SessionState],
|
|
281
|
+
*,
|
|
282
|
+
now: float,
|
|
283
|
+
decay: DecayPolicy = DEFAULT_DECAY,
|
|
284
|
+
) -> List[Project]:
|
|
285
|
+
"""Collapse sessions into projects, most recently active first.
|
|
286
|
+
|
|
287
|
+
A project's state is the highest-priority state among its sessions -
|
|
288
|
+
``waiting > error > done > working > idle``, the same rule the global
|
|
289
|
+
resolver uses - with recency breaking ties. Sessions with no ``cwd`` cannot
|
|
290
|
+
be attributed to a project and are dropped; there is nothing honest to show
|
|
291
|
+
for them, and inventing a slot would put a light on a key that no press can
|
|
292
|
+
reach.
|
|
293
|
+
"""
|
|
294
|
+
grouped: Dict[str, List[Tuple[AgentState, SessionState]]] = {}
|
|
295
|
+
for session in sessions:
|
|
296
|
+
path = normalise_project(session.cwd)
|
|
297
|
+
if not path:
|
|
298
|
+
continue
|
|
299
|
+
state = effective_state(session, now=now, decay=decay)
|
|
300
|
+
grouped.setdefault(path, []).append((state, session))
|
|
301
|
+
|
|
302
|
+
projects: List[Project] = []
|
|
303
|
+
for path, entries in grouped.items():
|
|
304
|
+
# Most interesting first: priority, then recency. entries[0] is both
|
|
305
|
+
# the project's state and the session an Agent Key press should reach.
|
|
306
|
+
entries.sort(key=lambda e: (e[0].priority, e[1].updated_at), reverse=True)
|
|
307
|
+
projects.append(
|
|
308
|
+
Project(
|
|
309
|
+
path=path,
|
|
310
|
+
state=entries[0][0],
|
|
311
|
+
last_active=max(session.updated_at for _, session in entries),
|
|
312
|
+
sessions=tuple(session for _, session in entries),
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
projects.sort(key=lambda p: p.last_active, reverse=True)
|
|
316
|
+
return _disambiguate(projects)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _disambiguate(projects: Sequence[Project]) -> List[Project]:
|
|
320
|
+
"""Give every project a label unique among the ones on screen.
|
|
321
|
+
|
|
322
|
+
Two checkouts both called ``web`` would otherwise show as two identical
|
|
323
|
+
keys, which defeats the point. Parent directories are added one at a time
|
|
324
|
+
until the labels differ (``api/web`` vs ``site/web``).
|
|
325
|
+
"""
|
|
326
|
+
result = list(projects)
|
|
327
|
+
labels = [_tail(p.path, 1) for p in result]
|
|
328
|
+
for depth in range(2, 7):
|
|
329
|
+
clashing = {
|
|
330
|
+
label for label, count in Counter(labels).items() if count > 1
|
|
331
|
+
}
|
|
332
|
+
if not clashing:
|
|
333
|
+
break
|
|
334
|
+
labels = [
|
|
335
|
+
_tail(project.path, depth) if label in clashing else label
|
|
336
|
+
for project, label in zip(result, labels)
|
|
337
|
+
]
|
|
338
|
+
return [
|
|
339
|
+
Project(
|
|
340
|
+
path=p.path,
|
|
341
|
+
state=p.state,
|
|
342
|
+
last_active=p.last_active,
|
|
343
|
+
sessions=p.sessions,
|
|
344
|
+
label=label or project_label(p.path),
|
|
345
|
+
)
|
|
346
|
+
for p, label in zip(result, labels)
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _tail(path: str, depth: int) -> str:
|
|
351
|
+
"""The last ``depth`` components of a path, for display only."""
|
|
352
|
+
parts = [part for part in path.split(os.sep) if part]
|
|
353
|
+
if not parts:
|
|
354
|
+
return path
|
|
355
|
+
return "/".join(parts[-depth:])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Slots
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True)
|
|
363
|
+
class AgentSlot:
|
|
364
|
+
"""What one Agent Key stands for right now.
|
|
365
|
+
|
|
366
|
+
A slot may be *empty* (nothing to show - the key is dark), or *reserved*: a
|
|
367
|
+
pinned directory with no live session, which is still dark but is never
|
|
368
|
+
given away to another project.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
index: int
|
|
372
|
+
#: Normalised project directory, or ``""`` when the slot has never been
|
|
373
|
+
#: given one. Non-empty with ``project is None`` means "reserved".
|
|
374
|
+
path: str = ""
|
|
375
|
+
project: Optional[Project] = None
|
|
376
|
+
pinned: bool = False
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def empty(self) -> bool:
|
|
380
|
+
"""True when this key has nothing live to show and must be dark."""
|
|
381
|
+
return self.project is None
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def reserved(self) -> bool:
|
|
385
|
+
"""Pinned to a directory that has no live session right now."""
|
|
386
|
+
return self.project is None and bool(self.path)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def state(self) -> Optional[AgentState]:
|
|
390
|
+
return self.project.state if self.project else None
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def label(self) -> str:
|
|
394
|
+
if self.project is not None:
|
|
395
|
+
return self.project.label
|
|
396
|
+
return project_label(self.path)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def last_active(self) -> float:
|
|
400
|
+
return self.project.last_active if self.project else 0.0
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def session(self) -> Optional[SessionState]:
|
|
404
|
+
"""The session an Agent Key press should jump to, if any."""
|
|
405
|
+
return self.project.lead if self.project else None
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def key_id(self) -> str:
|
|
409
|
+
"""The pad input id for this slot - ``AG00``…``AG05``."""
|
|
410
|
+
return f"AG{self.index:02d}"
|
|
411
|
+
|
|
412
|
+
def describe(self) -> str:
|
|
413
|
+
"""One line for ``freemicro status`` and the dry-run printer."""
|
|
414
|
+
if self.project is not None:
|
|
415
|
+
state = self.project.state.value
|
|
416
|
+
extra = (
|
|
417
|
+
f" ({self.project.session_count} sessions)"
|
|
418
|
+
if self.project.session_count > 1
|
|
419
|
+
else ""
|
|
420
|
+
)
|
|
421
|
+
return f"{self.label} - {state}{extra}"
|
|
422
|
+
if self.reserved:
|
|
423
|
+
return f"{self.label} - pinned, not running"
|
|
424
|
+
return "(empty)"
|
|
425
|
+
|
|
426
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
427
|
+
return {
|
|
428
|
+
"index": self.index,
|
|
429
|
+
"key": self.key_id,
|
|
430
|
+
"path": self.path,
|
|
431
|
+
"label": self.label,
|
|
432
|
+
"state": self.state.value if self.state else None,
|
|
433
|
+
"last_active": self.last_active,
|
|
434
|
+
"pinned": self.pinned,
|
|
435
|
+
"empty": self.empty,
|
|
436
|
+
"sessions": self.project.session_count if self.project else 0,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def resolve_slots(
|
|
441
|
+
config: Optional[AgentKeysConfig],
|
|
442
|
+
sessions: Sequence[SessionState],
|
|
443
|
+
*,
|
|
444
|
+
previous: Sequence[str] = (),
|
|
445
|
+
now: Optional[float] = None,
|
|
446
|
+
decay: DecayPolicy = DEFAULT_DECAY,
|
|
447
|
+
) -> List[AgentSlot]:
|
|
448
|
+
"""Assign the six Agent Keys. Pure - see the module docstring for the rule.
|
|
449
|
+
|
|
450
|
+
``previous`` is the path each slot held last time, which is what makes the
|
|
451
|
+
assignment sticky; pass ``()`` for a cold start. The caller owns that memory
|
|
452
|
+
(:class:`SlotResolver` does it for you) so this function stays a function.
|
|
453
|
+
|
|
454
|
+
``POLICY_MIRROR`` is resolved exactly like ``recent`` - the renderer is what
|
|
455
|
+
decides to ignore slots and paint all six keys one colour, and ``freemicro
|
|
456
|
+
status`` should still be able to say what *would* be on each key.
|
|
457
|
+
"""
|
|
458
|
+
config = config or AgentKeysConfig()
|
|
459
|
+
now = time.time() if now is None else now
|
|
460
|
+
projects = group_projects(sessions, now=now, decay=decay)
|
|
461
|
+
by_path: Dict[str, Project] = {p.path: p for p in projects}
|
|
462
|
+
|
|
463
|
+
assigned: List[str] = [""] * SLOT_COUNT
|
|
464
|
+
pinned: List[bool] = [False] * SLOT_COUNT
|
|
465
|
+
|
|
466
|
+
# 1. Pins are absolute, live or not.
|
|
467
|
+
for index, pin in enumerate(config.pins):
|
|
468
|
+
if pin:
|
|
469
|
+
assigned[index] = pin
|
|
470
|
+
pinned[index] = True
|
|
471
|
+
|
|
472
|
+
if config.policy != POLICY_MANUAL:
|
|
473
|
+
taken = {path for path in assigned if path}
|
|
474
|
+
|
|
475
|
+
# 2. Incumbents keep their key while they stay live.
|
|
476
|
+
for index, previous_path in enumerate(previous[:SLOT_COUNT]):
|
|
477
|
+
path = normalise_project(previous_path)
|
|
478
|
+
if assigned[index] or not path:
|
|
479
|
+
continue
|
|
480
|
+
if path in by_path and path not in taken:
|
|
481
|
+
assigned[index] = path
|
|
482
|
+
taken.add(path)
|
|
483
|
+
|
|
484
|
+
# 3. Vacancies go to the most recently active project without a key,
|
|
485
|
+
# filling from the left so the pad stays readable.
|
|
486
|
+
waiting = [p.path for p in projects if p.path not in taken]
|
|
487
|
+
for index in range(SLOT_COUNT):
|
|
488
|
+
if assigned[index] or not waiting:
|
|
489
|
+
continue
|
|
490
|
+
assigned[index] = waiting.pop(0)
|
|
491
|
+
|
|
492
|
+
return [
|
|
493
|
+
AgentSlot(
|
|
494
|
+
index=index,
|
|
495
|
+
path=assigned[index],
|
|
496
|
+
project=by_path.get(assigned[index]),
|
|
497
|
+
pinned=pinned[index],
|
|
498
|
+
)
|
|
499
|
+
for index in range(SLOT_COUNT)
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@dataclass
|
|
504
|
+
class SlotResolver:
|
|
505
|
+
"""A :func:`resolve_slots` that remembers, so slots stay put.
|
|
506
|
+
|
|
507
|
+
Holds one thing: the path each key last stood for. A key that goes empty
|
|
508
|
+
*keeps* that memory (rule 4) until another project actually takes the slot,
|
|
509
|
+
so closing a terminal and reopening it lands you back on the same key.
|
|
510
|
+
|
|
511
|
+
The decay policy is the store's, whole (:meth:`for_store`). The keys are
|
|
512
|
+
lit from records the store has *already* decayed, and re-deciding here
|
|
513
|
+
under different timers means the stricter of the two wins whatever the user
|
|
514
|
+
configured: raise ``working_ttl_seconds`` and the store honours it while
|
|
515
|
+
the pad quietly undoes it.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
config: AgentKeysConfig = field(default_factory=AgentKeysConfig)
|
|
519
|
+
decay: DecayPolicy = DEFAULT_DECAY
|
|
520
|
+
_previous: Tuple[str, ...] = field(default=("",) * SLOT_COUNT, init=False)
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def for_store(
|
|
524
|
+
cls, store: Any, *, config: Optional[AgentKeysConfig] = None
|
|
525
|
+
) -> "SlotResolver":
|
|
526
|
+
"""A resolver that decays exactly the way ``store`` does."""
|
|
527
|
+
return cls(config=config or AgentKeysConfig(), decay=decay_of(store))
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def previous(self) -> Tuple[str, ...]:
|
|
531
|
+
return self._previous
|
|
532
|
+
|
|
533
|
+
def resolve(
|
|
534
|
+
self,
|
|
535
|
+
sessions: Sequence[SessionState],
|
|
536
|
+
*,
|
|
537
|
+
now: Optional[float] = None,
|
|
538
|
+
) -> List[AgentSlot]:
|
|
539
|
+
slots = resolve_slots(
|
|
540
|
+
self.config,
|
|
541
|
+
sessions,
|
|
542
|
+
previous=self._previous,
|
|
543
|
+
now=now,
|
|
544
|
+
decay=self.decay,
|
|
545
|
+
)
|
|
546
|
+
self._previous = tuple(
|
|
547
|
+
slot.path or self._previous[slot.index] for slot in slots
|
|
548
|
+
)
|
|
549
|
+
return slots
|
|
550
|
+
|
|
551
|
+
def seed(self, previous: Sequence[str]) -> None:
|
|
552
|
+
"""Start from a known assignment - e.g. one restored from disk."""
|
|
553
|
+
entries = [normalise_project(entry) for entry in list(previous)[:SLOT_COUNT]]
|
|
554
|
+
entries.extend([""] * (SLOT_COUNT - len(entries)))
|
|
555
|
+
self._previous = tuple(entries)
|
|
556
|
+
|
|
557
|
+
def forget(self) -> None:
|
|
558
|
+
"""Drop the memory - the next resolve starts from a cold pad."""
|
|
559
|
+
self._previous = ("",) * SLOT_COUNT
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def slot_for_project(slots: Sequence[AgentSlot], path: str) -> Optional[AgentSlot]:
|
|
563
|
+
"""Find the slot showing ``path``, if any."""
|
|
564
|
+
wanted = normalise_project(path)
|
|
565
|
+
for slot in slots:
|
|
566
|
+
if slot.path == wanted:
|
|
567
|
+
return slot
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
__all__ = [
|
|
572
|
+
"AgentKeysConfig",
|
|
573
|
+
"AgentKeysError",
|
|
574
|
+
"AgentSlot",
|
|
575
|
+
"POLICIES",
|
|
576
|
+
"POLICY_MANUAL",
|
|
577
|
+
"POLICY_MIRROR",
|
|
578
|
+
"POLICY_PINNED",
|
|
579
|
+
"POLICY_RECENT",
|
|
580
|
+
"Project",
|
|
581
|
+
"SLOT_COUNT",
|
|
582
|
+
"SlotResolver",
|
|
583
|
+
"effective_state",
|
|
584
|
+
"group_projects",
|
|
585
|
+
"normalise_project",
|
|
586
|
+
"parse_agent_keys",
|
|
587
|
+
"project_label",
|
|
588
|
+
"resolve_slots",
|
|
589
|
+
"slot_for_project",
|
|
590
|
+
]
|