Skip to content

Commit e31f960

Browse files
committed
feat: initial commit
0 parents  commit e31f960

23 files changed

Lines changed: 1640 additions & 0 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.build
2+
.swiftpm
3+
DerivedData
4+
dist
5+
*.xcodeproj/project.xcworkspace/xcuserdata
6+
*.xcodeproj/xcuserdata

Package.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// swift-tools-version: 6.2
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "Loopndroll",
6+
platforms: [
7+
.macOS(.v14),
8+
],
9+
products: [
10+
.library(
11+
name: "LoopndrollCore",
12+
targets: ["LoopndrollCore"]
13+
),
14+
.executable(
15+
name: "Loopndroll",
16+
targets: ["LoopndrollApp"]
17+
),
18+
.executable(
19+
name: "LoopndrollHook",
20+
targets: ["LoopndrollHook"]
21+
),
22+
],
23+
targets: [
24+
.target(
25+
name: "LoopndrollCore"
26+
),
27+
.executableTarget(
28+
name: "LoopndrollApp",
29+
dependencies: ["LoopndrollCore"]
30+
),
31+
.executableTarget(
32+
name: "LoopndrollHook",
33+
dependencies: ["LoopndrollCore"]
34+
),
35+
.testTarget(
36+
name: "LoopndrollCoreTests",
37+
dependencies: ["LoopndrollCore"]
38+
),
39+
]
40+
)

Packaging/Info.plist

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>en</string>
7+
<key>CFBundleDisplayName</key>
8+
<string>Loopndroll</string>
9+
<key>CFBundleExecutable</key>
10+
<string>Loopndroll</string>
11+
<key>CFBundleIdentifier</key>
12+
<string>dev.loopndroll.app</string>
13+
<key>CFBundleInfoDictionaryVersion</key>
14+
<string>6.0</string>
15+
<key>CFBundleName</key>
16+
<string>Loopndroll</string>
17+
<key>CFBundlePackageType</key>
18+
<string>APPL</string>
19+
<key>CFBundleShortVersionString</key>
20+
<string>0.1.0</string>
21+
<key>CFBundleVersion</key>
22+
<string>1</string>
23+
<key>LSApplicationCategoryType</key>
24+
<string>public.app-category.developer-tools</string>
25+
<key>LSMinimumSystemVersion</key>
26+
<string>14.0</string>
27+
<key>LSUIElement</key>
28+
<true/>
29+
<key>NSHighResolutionCapable</key>
30+
<true/>
31+
</dict>
32+
</plist>

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Loopndroll
2+
3+
Loopndroll is a macOS menu bar app that installs a global Codex `Stop` hook under `~/.codex` and can keep Codex sessions running either indefinitely or for a per-thread turn budget.
4+
5+
## What it does
6+
7+
- Adds `codex_hooks = true` to `~/.codex/config.toml`
8+
- Creates or merges a managed `Stop` hook in `~/.codex/hooks.json`
9+
- Stores its runtime state in `~/Library/Application Support/loopndroll/state.json`
10+
- Keeps the hook installed even when the app is off; the helper checks app state and no-ops when disabled
11+
12+
The managed hook uses Codex `Stop` hooks, which continue Codex by returning `{"decision":"block","reason":"..."}` to the runtime.
13+
14+
## Important behavior
15+
16+
- Hooks apply reliably to new Codex threads created after Loopndroll is installed.
17+
- If a Codex thread was already running before the hook was installed, that existing thread may not pick up the new hook dynamically.
18+
- Toggling `Start` or `Stop` updates shared state immediately for threads that already know about the hook.
19+
20+
## Install
21+
22+
### Prebuilt app
23+
24+
1. Download `Loopndroll.app.zip` from a release or the `dist/` folder produced by the packaging script.
25+
2. Unzip it.
26+
3. Move `Loopndroll.app` into `/Applications` or another permanent location.
27+
4. Launch the app and allow it to run.
28+
5. Open a new Codex thread after first install.
29+
30+
This repository currently packages an unsigned app bundle with ad-hoc signing only. On another Mac, Gatekeeper may require right-click -> `Open` on first launch, or the app may need to be notarized before broader distribution.
31+
32+
### Build from source
33+
34+
```bash
35+
swift build
36+
swift run Loopndroll
37+
```
38+
39+
## Package for sharing
40+
41+
```bash
42+
./scripts/package_app.sh
43+
```
44+
45+
That command creates:
46+
47+
- `dist/Loopndroll.app`
48+
- `dist/Loopndroll.app.zip`
49+
50+
## Structure
51+
52+
- `Sources/LoopndrollCore`: install/repair logic, state store, hook decision engine, and prompt rendering
53+
- `Sources/LoopndrollApp`: SwiftUI menu bar app
54+
- `Sources/LoopndrollHook`: dedicated helper executable for tests and direct CLI use
55+
- `Packaging/Info.plist`: app bundle metadata used by the packager
56+
- `scripts/package_app.sh`: release packaging script
57+
58+
The app binary also supports `--hook` mode so the installed hook can point at a stable copied executable in `~/Library/Application Support/loopndroll/bin/loopndroll-hook`.
59+
60+
## Build
61+
62+
```bash
63+
swift build
64+
```
65+
66+
## Run
67+
68+
```bash
69+
swift run Loopndroll
70+
```
71+
72+
## Test
73+
74+
```bash
75+
swift test
76+
```
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import AppKit
2+
import Foundation
3+
import LoopndrollCore
4+
import Observation
5+
6+
@MainActor
7+
@Observable
8+
final class AppModel {
9+
var state: PersistedState = .defaultValue
10+
var installationSummary = "Checking installation..."
11+
var installationHealthy = false
12+
var busy = false
13+
var errorMessage: String?
14+
15+
private let paths: LoopndrollPaths
16+
private let executableURL: URL
17+
private var hasStarted = false
18+
19+
init(
20+
paths: LoopndrollPaths = .live(),
21+
executableURL: URL = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0])
22+
) {
23+
self.paths = paths
24+
self.executableURL = executableURL
25+
}
26+
27+
func startIfNeeded() {
28+
guard !hasStarted else { return }
29+
hasStarted = true
30+
31+
Task {
32+
await refreshState()
33+
await repairInstallation()
34+
}
35+
}
36+
37+
func setEnabled(_ enabled: Bool) {
38+
Task {
39+
await updateState { state in
40+
state.config.enabled = enabled
41+
state.startNewActivation()
42+
}
43+
}
44+
}
45+
46+
func setMode(_ mode: LoopMode) {
47+
Task {
48+
await updateState { state in
49+
state.config.mode = mode
50+
state.startNewActivation()
51+
}
52+
}
53+
}
54+
55+
func setMaxTurns(_ value: Int) {
56+
Task {
57+
await updateState { state in
58+
state.config.maxTurns = max(1, value)
59+
state.startNewActivation()
60+
}
61+
}
62+
}
63+
64+
func setPromptTemplate(_ promptTemplate: String) {
65+
Task {
66+
await updateState { state in
67+
state.config.promptTemplate = promptTemplate
68+
state.touch()
69+
}
70+
}
71+
}
72+
73+
func resetBudgets() {
74+
Task {
75+
await updateState { state in
76+
state.startNewActivation()
77+
}
78+
}
79+
}
80+
81+
func repairNow() {
82+
Task {
83+
await repairInstallation()
84+
}
85+
}
86+
87+
func openCodexFolder() {
88+
NSWorkspace.shared.open(paths.codexDirectoryURL)
89+
}
90+
91+
func openAppSupportFolder() {
92+
NSWorkspace.shared.open(paths.appDirectoryURL)
93+
}
94+
95+
func quit() {
96+
NSApplication.shared.terminate(nil)
97+
}
98+
99+
private func refreshState() async {
100+
let stateStore = StateStore(stateURL: paths.stateURL, lockURL: paths.lockURL)
101+
102+
do {
103+
let loadedState = try await runIO {
104+
try stateStore.load()
105+
}
106+
state = loadedState
107+
errorMessage = nil
108+
} catch {
109+
errorMessage = "Failed to load Loopndroll state: \(error.localizedDescription)"
110+
}
111+
}
112+
113+
private func repairInstallation() async {
114+
busy = true
115+
defer { busy = false }
116+
117+
do {
118+
let paths = self.paths
119+
let executableURL = self.executableURL
120+
let report = try await runIO {
121+
try InstallationManager(paths: paths).repair(using: executableURL)
122+
}
123+
124+
installationHealthy = report.health.isHealthy
125+
if report.health.isHealthy {
126+
if report.didUpdateExecutable || report.didUpdateConfig || report.didUpdateHooks || report.didCreateState {
127+
installationSummary = "Installed and repaired."
128+
} else {
129+
installationSummary = "Installed and healthy."
130+
}
131+
} else {
132+
installationSummary = report.health.issues.joined(separator: " ")
133+
}
134+
135+
if report.replacedMalformedHooks {
136+
errorMessage = "hooks.json was malformed and was replaced with a repaired file."
137+
} else {
138+
errorMessage = nil
139+
}
140+
141+
await refreshState()
142+
} catch {
143+
installationHealthy = false
144+
installationSummary = "Installation needs repair."
145+
errorMessage = "Repair failed: \(error.localizedDescription)"
146+
}
147+
}
148+
149+
private func updateState(_ mutate: @escaping @Sendable (inout PersistedState) -> Void) async {
150+
busy = true
151+
defer { busy = false }
152+
153+
let stateStore = StateStore(stateURL: paths.stateURL, lockURL: paths.lockURL)
154+
155+
do {
156+
let updatedState = try await runIO {
157+
let (state, _) = try stateStore.mutate { state in
158+
mutate(&state)
159+
}
160+
return state
161+
}
162+
163+
state = updatedState
164+
errorMessage = nil
165+
} catch {
166+
errorMessage = "Failed to save Loopndroll state: \(error.localizedDescription)"
167+
}
168+
}
169+
170+
private func runIO<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
171+
try await Task.detached(priority: .userInitiated, operation: operation).value
172+
}
173+
}

0 commit comments

Comments
 (0)