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.
Files changed (63) hide show
  1. freemicro/__init__.py +18 -0
  2. freemicro/__main__.py +6 -0
  3. freemicro/agentkeys.py +590 -0
  4. freemicro/cli.py +2368 -0
  5. freemicro/compat.py +273 -0
  6. freemicro/config.py +107 -0
  7. freemicro/daemon.py +553 -0
  8. freemicro/default_keymap.json +365 -0
  9. freemicro/detector/__init__.py +5 -0
  10. freemicro/detector/probe.py +190 -0
  11. freemicro/device/__init__.py +192 -0
  12. freemicro/device/codex_micro.py +678 -0
  13. freemicro/device/lighting.py +347 -0
  14. freemicro/diagnostics.py +628 -0
  15. freemicro/focus.py +491 -0
  16. freemicro/hooks_install.py +317 -0
  17. freemicro/input/__init__.py +19 -0
  18. freemicro/input/actions.py +999 -0
  19. freemicro/input/bridge.py +1131 -0
  20. freemicro/input/keys.py +273 -0
  21. freemicro/input/latch.py +176 -0
  22. freemicro/input/pointer.py +501 -0
  23. freemicro/input/quartz.py +434 -0
  24. freemicro/lighting_owner.py +836 -0
  25. freemicro/menubar/__init__.py +34 -0
  26. freemicro/menubar/__main__.py +15 -0
  27. freemicro/menubar/app.py +670 -0
  28. freemicro/menubar/checks.py +234 -0
  29. freemicro/menubar/cocoa.py +381 -0
  30. freemicro/menubar/model.py +438 -0
  31. freemicro/menubar/status.py +560 -0
  32. freemicro/onboarding.py +513 -0
  33. freemicro/padconfig.py +1383 -0
  34. freemicro/permission_prompt.py +449 -0
  35. freemicro/permissions.py +212 -0
  36. freemicro/renderers/__init__.py +37 -0
  37. freemicro/renderers/base.py +157 -0
  38. freemicro/renderers/micro_leds.py +1045 -0
  39. freemicro/selftest.py +538 -0
  40. freemicro/staleness.py +1133 -0
  41. freemicro/state/__init__.py +43 -0
  42. freemicro/state/engine.py +1374 -0
  43. freemicro/state/hooks.py +256 -0
  44. freemicro/state/slots.py +91 -0
  45. freemicro/uninstall.py +926 -0
  46. freemicro/webui/__init__.py +40 -0
  47. freemicro/webui/api.py +479 -0
  48. freemicro/webui/apps.py +102 -0
  49. freemicro/webui/configio.py +529 -0
  50. freemicro/webui/keycaps.py +375 -0
  51. freemicro/webui/layout.py +401 -0
  52. freemicro/webui/layouts.py +209 -0
  53. freemicro/webui/padlink.py +664 -0
  54. freemicro/webui/server.py +421 -0
  55. freemicro/webui/starters.py +420 -0
  56. freemicro/webui/static/app.css +1041 -0
  57. freemicro/webui/static/app.js +3168 -0
  58. freemicro/webui/static/index.html +101 -0
  59. freemicro-0.1.0.dist-info/METADATA +566 -0
  60. freemicro-0.1.0.dist-info/RECORD +63 -0
  61. freemicro-0.1.0.dist-info/WHEEL +4 -0
  62. freemicro-0.1.0.dist-info/entry_points.txt +2 -0
  63. 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
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m freemicro``."""
2
+
3
+ from freemicro.cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ raise SystemExit(main())
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
+ ]