# Project export: Vibe Signal

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Vibe Signal connects Codex to iPhone and Apple Watch, streaming live states so you can monitor progress, approve requests, send voice prompts, and step away from your desk.
- Devpost: https://devpost.com/software/vibe-signal
- GitHub: https://github.com/TangyrenMidKing/vibe-signal
- Demo: https://open-vsx.org/extension/ShiboDing/vibe-signal
- Video: https://www.youtube.com/embed/W25wAU_Jwuc?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Shibo (41 commits), Cursor (10 commits)

## Devpost submission (written by the team)

### Inspiration

Vibe coding is changing how we build software, but it still keeps us tied to the desk. We were inspired by physical AI coding controllers that make agent activity feel tangible, and asked: what if those controls moved with us on devices we already carry?

### What it does

Vibe Signal connects Codex to an iPhone and Apple Watch. It displays the agent’s live state—idle, working, waiting, completed, or error—and uses colors and haptics to make each state recognizable at a glance. From the Watch, users can approve or deny requests, continue or retry a turn, stop an active task, and send new prompts by voice. Pairing requires only a QR scan, and communication stays on the local Wi-Fi network.

### How we built it

We built Vibe Signal as three connected layers: A TypeScript VS Code extension receives Codex lifecycle events through hooks. A local WebSocket server converts those events into a consistent state protocol and accepts remote commands. Native SwiftUI apps for iPhone and Apple Watch display status and send actions back through WatchConnectivity. The iPhone acts as the bridge between the desktop and Watch. Pairing data includes the computer’s local address, port, and authentication token. Voice recordings travel from the Watch to the iPhone for transcription, then return to Codex as prompts.

### Challenges we ran into

The hardest challenge was making several systems behave like one reliable product. Codex hooks, WebSockets, iOS background execution, WatchConnectivity, speech recognition, audio playback, and Xcode signing all have different lifecycle constraints. We had to handle stale state, reconnect dropped sockets, prevent duplicate commands, keep the iPhone bridge active in the background, and make audio continue after the Watch screen dims. We also worked through delayed Watch file transfers, speech-recognition timing, and compatibility with older generated Xcode projects.

### Accomplishments we're proud of

We are most proud that Vibe Signal closes the complete coding loop from the wrist. A user can describe a change by voice, send it to Codex, follow the agent’s progress, respond when it needs input, and receive the result without returning to the computer. We also built a local-first architecture with QR pairing, token authentication, live state synchronization, background connectivity, distinct haptics, and high-quality spoken responses.

### What we learned

We learned that a useful AI companion does not need to reproduce the entire desktop interface. A small number of clear states and contextual actions can communicate what matters while reducing distraction. We also learned how important lifecycle design is across Apple devices. A connection that works while both apps are open is only the beginning; background suspension, audio sessions, network changes, and delayed Watch transfers must all be treated as normal operating conditions.

### What's next

Next, we want to add a secure cloud relay so Vibe Signal can work beyond the local Wi-Fi network. We also plan to support more coding agents, multiple computers, richer notifications, Dynamic Island and Live Activities, and a macOS menu bar companion. The larger vision is to make agent state available wherever the developer is—not by turning every device into a remote desktop, but by delivering the right signal and controls at the right moment.

## README (from the GitHub repository)

# Vibe Signal

> Let your AI coding agent stay connected when you leave the desk.

**Vibe Signal** is a companion system for AI coding agents. A VS Code extension watches [Codex](https://github.com/openai/codex) via lifecycle hooks, pushes live status over the local Wi‑Fi to your iPhone, and mirrors it to Apple Watch with haptics and Approve / Continue / voice controls.

```
Codex CLI ──hooks──► VS Code Extension (connector)
                              │
                     WebSocket (same Wi‑Fi)
                              │
                           iPhone
                              │
                      WatchConnectivity
                              │
                         Apple Watch
```

See [PROTOCOL.md](PROTOCOL.md) for the wire formats.

## How we used Codex and GPT-5.6

### Codex powers the product

Vibe Signal is built directly around the **Codex CLI**. Codex lifecycle hooks such as `SessionStart`, `PostToolUse`, `PermissionRequest`, and `Stop` are translated into a small set of live states that can be understood at a glance. When Codex needs input, Vibe Signal sends the decision back from the iPhone or Apple Watch. Voice prompts can also start or continue a Codex turn, completing the loop from wrist to coding agent.

This is more than a status display: Codex is the active agent behind the experience, while Vibe Signal provides a mobile control and notification layer around it.

### GPT-5.6 helped us build it

We used **GPT-5.6 in Cursor** as an engineering collaborator throughout development. It helped us:

- Design the local-first architecture and shared WebSocket protocol
- Implement the TypeScript VS Code connector and native SwiftUI apps
- Debug state synchronization across Codex, iPhone, and Apple Watch
- Work through iOS background execution, WatchConnectivity, speech, and audio lifecycle issues
- Create tests, refine the demo flow, and document the project

GPT-5.6 was especially valuable when changes crossed multiple platforms. It could reason about the connector, protocol, and Apple clients together instead of treating each component as an isolated codebase.

## Built with

TypeScript, Node.js, Swift, SwiftUI, iOS, watchOS, Apple Watch, VS Code Extension API, Codex CLI, GPT-5.6, WebSockets, WatchConnectivity, AVFoundation, Speech Recognition, UserNotifications, URLSession, QR pairing, Xcode, and XcodeGen.

## Repo layout

| Path | Role |
|------|------|
| [`connector/`](connector/) | VS Code extension — local HTTP + LAN WebSocket + Codex hook installer |
| [`app/`](app/) | SwiftUI iPhone + Apple Watch sources (build on a Mac) |
| [`PROTOCOL.md`](PROTOCOL.md) | Shared message contracts |

## MVP features

- Red / yellow / green status on Watch and iPhone (`working` / `waiting` / `completed`)
- Distinct haptics per state change
- Approve / Deny for Codex `PermissionRequest`
- Continue / Retry / voice prompt after `Stop`
- Same-WiFi realtime sync via QR pairing

---

## 1. Desktop connector (VS Code)

### Requirements

- Node.js 18+
- VS Code (or Cursor with VS Code extension compatibility)
- Codex CLI with hooks enabled

### Install / run from source

```bash
cd connector
npm install
npm run compile
```

In VS Code: **Extensions → … → Install from VSIX…** after packaging, or use the Extension Development Host:

1. Open the `connector` folder in VS Code
2. Press `F5` (Run Extension)
3. In the Extension Development Host, confirm the status bar shows `Vibe Signal: idle`

### Sidebar UI

Click the **Vibe Signal** pulse icon in the Activity Bar:

- Toggle **Connector On/Off** (server only listens when On)
- Live state, detail, client count, host:port, token, health URL
- Buttons for pair QR, copy pairing JSON, Codex hooks, simulate, rotate token

Status bar shows Off / current state; click it to open the sidebar.

### Useful commands

| Command | Purpose |
|---------|---------|
| **Vibe Signal: Toggle Connector** | Enable / disable the LAN server |
| **Vibe Signal: Open Sidebar** | Focus the Vibe Signal panel |
| **Vibe Signal: Pair Device** | QR webview for iPhone |
| **Vibe Signal: Setup Codex Hooks** | Merge hooks into `~/.codex/hooks.json` |
| **Vibe Signal: Simulate Event** | Drive states without Codex |
| **Vibe Signal: Copy Pairing Info** | Copy host/port/token JSON |
| **Vibe Signal: Show Status** | Modal status dump |

Settings (`agentpulse.*`):

- `port` (default `8787`)
- `permissionTimeoutMs` (default `120000`)
- `stopTimeoutMs` (default `60000`)

### Dev harness (no VS Code)

```bash
cd connector
npm run compile
# PowerShell
$env:AGENTPULSE_TOKEN='test-token-mvp'; node scripts/dev-server.js
# then
node scripts/ws-client.js 127.0.0.1 8787 test-token-mvp
```

---

## 2. Wire Codex hooks

1. Start the extension (so the port is listening).
2. Run **Vibe Signal: Setup Codex Hooks**.
3. This copies `hooks/hook.js` → `~/.agentpulse/hook.js` and merges Vibe Signal entries into `~/.codex/hooks.json`.
4. In Codex CLI, run **`/hooks`**, review, and **trust** the Vibe Signal command hooks.
5. Start a Codex turn — status should move to **working**, approvals to **waiting**, finish to **completed**.

Without trust, Codex skips the hooks silently.

**Cancel** in MVP = Deny a pending permission request (Codex has no interrupt-via-hook).

---

## 3. iPhone + Apple Watch app

Sources live under [`app/`](app/). They must be compiled on a Mac with Xcode 15+ (iOS 17 / watchOS 10).

### Generate the Xcode project

```bash
# on macOS
brew install xcodegen   # once
cd app
xcodegen generate
open VibeSignal.xcodeproj
```

### Xcode steps

1. Select your **Team** under Signing for both `VibeSignal` and `VibeSignalWatch`.
2. Set a unique bundle id if `com.vibesignal.app` is taken.
3. Run the **VibeSignal** scheme on a physical iPhone (camera + local network; Watch optional).
4. Install the Watch app from the Phone’s Watch app if needed.

### Pairing

1. On desktop: **Vibe Signal: Pair Device**.
2. On phone: scan the QR (or paste JSON / enter host + port + token).
3. Phone and desktop must be on the **same Wi‑Fi**. Status becomes **Live**.
4. Watch updates automatically via WatchConnectivity while the iPhone app is paired/reachable.

### Permissions

- Camera (QR)
- Microphone + Speech (voice prompts)
- Local Network (LAN WebSocket)

---

## States & controls

| State | Color | Typical trigger | Watch actions |
|-------|-------|-----------------|---------------|
| Working | Red | Session / prompt / tool use | Mic |
| Waiting | Yellow | `PermissionRequest` | Approve / Deny |
| Completed | Green | `Stop` | Continue / Mic |
| Error | Orange | Best-effort non-zero Bash | Continue / Mic |

Haptics: start (working), triple notification (waiting), failure (error), success (completed).

---

## Phase 2 / 3 (not in MVP)

Dynamic Island, menu bar app, cloud relay, internet remote, multi-device.

---

## License

MIT (or your choice once published).


## Detected evidence (automated analysis)

Indexed codebase: 41 recognized source files, 257 KB.
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Swift (language) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (63 of 63)

```
.gitignore
.impeccable/live/config.json
.vscode/launch.json
.vscode/tasks.json
app/iOS/AgentPulse.entitlements
app/iOS/AgentPulseApp.swift
app/iOS/AppModel.swift
app/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
app/iOS/Assets.xcassets/Contents.json
app/iOS/Info.plist
app/iOS/ManualPairingView.swift
app/iOS/PhoneSession.swift
app/iOS/QRScannerView.swift
app/iOS/RootView.swift
app/iOS/VoicePromptView.swift
app/project.yml
app/README.md
app/Shared/AgentPulseClient.swift
app/Shared/FeedbackLogView.swift
app/Shared/Models.swift
app/Shared/Theme.swift
app/Shared/WCKeys.swift
app/VibeSignal.xcodeproj/project.pbxproj
app/VibeSignal.xcodeproj/project.xcworkspace/contents.xcworkspacedata
app/VibeSignal.xcodeproj/xcshareddata/xcschemes/VibeSignal.xcscheme
app/VibeSignal.xcodeproj/xcuserdata/ding.xcuserdatad/xcschemes/xcschememanagement.plist
app/Watch/AgentPulseWatchApp.swift
app/Watch/Assets.xcassets/AppIcon.appiconset/Contents.json
app/Watch/Assets.xcassets/Contents.json
app/Watch/Info.plist
app/Watch/WatchModel.swift
app/Watch/WatchStatusView.swift
connector/.vscode/launch.json
connector/.vscode/tasks.json
connector/.vscodeignore
connector/hooks/hook.js
connector/LICENSE
connector/package.json
connector/README.md
connector/scripts/dev-server.js
connector/scripts/reinstall-hooks.js
connector/scripts/ws-client.js
connector/src/codexLauncher.ts
connector/src/controller.ts
connector/src/decisionHub.ts
connector/src/extension.ts
connector/src/hookInstaller.ts
connector/src/network.ts
connector/src/pairing.ts
connector/src/projectInfo.ts
connector/src/server.ts
connector/src/sidebar.ts
connector/src/state.ts
connector/src/types.ts
connector/test/flow.test.js
connector/test/idle-voice.test.js
connector/tsconfig.json
demo/index.html
demo/SCRIPT.md
LICENSE
PRODUCT.md
PROTOCOL.md
README.md
```

### Dependencies

- connector/package.json: @types/node@^20.11.0, @types/vscode@^1.85.0, @types/ws@^8.5.10, typescript@^5.3.3, ws@^8.18.0

### Recent commits (newest first)

- update read me
- demo
- commit
- fix iphone app error
- iphone app background hosting
- fallback tts
- tts
- open ai tts
- Add remote turn cancellation controls
- watch background play + stop read button
- fix xcode build
- open ai tts
- watch read response issue
- more color
- watch ui background light
- watch UI update
- build fille
- fix watch speach
- fix watch UI
- v0.1.5

## Key source files (fetched from GitHub, selected and truncated for size)

### PRODUCT.md

```markdown
# Product

## Register

product

## Users

Primary users are developers who leave the desk while an AI coding agent (Codex and, later, others) runs. Context: kitchen, couch, standup, or remote collaboration away from the IDE.

Job to be done: stay aware of agent state and intervene quickly (approve, continue, voice) without staring at the screen. Success feels like: *the agent finds you when it needs you; you glance once and act — then return to life / collab.*

Secondary context called out by the team: **remote collaboration** — partners who need the same calm signal when someone else’s agent is waiting on them.

## Product Purpose

Vibe Signal is a companion system across VS Code/Cursor (connector), iPhone, and Apple Watch. It is not “remote desktop for AI.” It reduces wait-staring by pushing live agent status (idle / working / waiting / completed / error), haptics, and a small set of controls over the same Wi‑Fi.

Success looks like fewer minutes spent watching the agent, fewer missed approval prompts, and a shared, glanceable pulse during remote collab sessions.

## Brand Personality

**智能 · 远程 · 易操控** (intelligent · remote · easy to control)

Voice: calm and precise, never chatty marketing. Prefer short status language (“Needs you”, “Standing by”) over slogans. The product should feel like a reliable field instrument for remote work — signal first, chrome second.

Emotional goals: trust, composure, readiness — not excitement, neon spectacle, or “AI magic” theater.

## Anti-references

Explicitly avoid:

- **AI-cheap UI**: rainbow accents, decorative gradients, glassmorphism for its own sake
- **Card spam**: nested cards, dashboard tiles, stat strips that turn a glance surface into a cockpit
- Purple / indigo “default SaaS AI” themes and soft cream-paper marketing skins
- Cyberpunk HUD neon overload and busy widget chrome
- Anything where removing color would make the UI unintelligible (status must also use labels / haptics / layout)

## Design Principles

1. **Signal over spectacle** — One luminous status state; everything else supports that glance.
2. **Remote-ready composure** — UI must read in one look at arm’s length (phone) or wrist (watch).
3. **Few, confident controls** — Approve / Deny / Continue / hold-to-talk; no feature menus masquerading as companion UX.
4. **Practiced restraint** — No decorative cards or color misuse; hierarchy from type, space, and one accent.
5. **Agent finds you** — Design for interrupt + return-to-life, not lingering in the app.

## Accessibility & Inclusion

- Target **WCAG 2.2 AA** contrast for text on ink surfaces.
- Status never relies on color alone: keep labels, Watch haptics, and distinct motion cues.
- Respect **reduced motion**: prefer crossfades / opacity over pulsing loops when users opt out.
- Hold-to-talk must remain operable with clear pressed/listening feedback (haptic + visible state).
- Default language in product UI: concise English; docs may be bilingual as needed.

```

### PROTOCOL.md

```markdown
# Vibe Signal Protocol v0.1

Single source of truth for WebSocket messages and the local hook HTTP contract between the VS Code connector and clients (iPhone, hook scripts, simulators).

## States

| State       | Color  | Meaning                                      |
|-------------|--------|----------------------------------------------|
| `working`   | red    | Agent is actively planning / coding / editing |
| `waiting`   | yellow | Needs user approval or input                 |
| `completed` | green  | Turn finished successfully                   |
| `error`     | red+   | Failure or non-zero tool result (best-effort)|
| `idle`      | gray   | No active session / disconnected             |

## Commands (phone → connector)

| Command        | Meaning                                              |
|----------------|------------------------------------------------------|
| `approve`      | Allow pending `PermissionRequest`                    |
| `deny`         | Deny pending `PermissionRequest` (Cancel in MVP)     |
| `continue`     | Send a default continue prompt via `Stop` hook       |
| `retry`        | Same as continue with a "retry" reason               |
| `voice_prompt` | Free-text prompt injected via `Stop`; if idle/completed/error, starts/resumes one Codex exec turn in a **visible VS Code terminal** named “Vibe Signal” (never while `working`/`waiting`) |
| `stop`         | Abort the active turn: kill the Vibe Signal Codex terminal if we own it, clear pending decisions, move state to `completed` |

---

## WebSocket (LAN)

### Connection

```
ws://<lan-ip>:<port>/?token=<pairing-token>
```

- Server binds to `0.0.0.0` (all interfaces) on a chosen port (default `8787`).
- Auth: query `token` must match the connector pairing token. Mismatch → close `4401`.
- Keepalive: server sends JSON ping every 25s; client should reply with `{ "type": "pong" }`.

### Server → Client

**State snapshot** (pushed on change and on connect):

```json
{
  "type": "state",
  "state": "working | waiting | completed | error | idle",
  "detail": "Human-readable summary",
  "sessionId": "optional-codex-session-id",
  "turnId": "optional-turn-id",
  "project": "Jareturn",
  "repo": "owner/jareturn",
  "cwd": "D:\\\\Code\\\\Jareturn",
  "ts": 1710000000000
}
```

`project` is typically the workspace / folder name. `repo` is the git remote identity (`owner/name`) when available, otherwise the git root folder name.
**Ping**:

```json
{ "type": "ping", "ts": 1710000000000 }
```

**Ack** (optional confirmation of a command):

```json
{
  "type": "ack",
  "command": "approve",
  "ok": true,
  "message": "optional"
}
```

### Client → Server

**Command**:

```json
{
  "type": "command",
  "command": "approve | deny | continue | retry | voice_prompt",
  "text": "optional free text for voice_prompt / continue",
  "id": "optional client request id"
}
```

**Pong**:

```json
{ "type": "pong" }
```

---

## Pairing QR payload

Scanned by the iPhone app. JSON string:

```json

[truncated — 2226 more characters]
```

### connector/package.json

```
{
  "name": "vibe-signal",
  "displayName": "Vibe Signal",
  "description": "Keep your AI coding agent connected to your Apple Watch and iPhone.",
  "version": "0.1.7",
  "publisher": "ShiboDing",
  "license": "MIT",
  "icon": "media/icon.png",
  "repository": {
    "type": "git",
    "url": "https://github.com/TangyrenMidKing/vibe-signal.git"
  },
  "homepage": "https://github.com/TangyrenMidKing/vibe-signal#readme",
  "bugs": {
    "url": "https://github.com/TangyrenMidKing/vibe-signal/issues"
  },
  "engines": {
    "vscode": "^1.85.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [
    "onStartupFinished",
    "onView:agentpulse.sidebar",
    "onCommand:agentpulse.toggle",
    "onCommand:agentpulse.focusSidebar"
  ],
  "main": "./out/extension.js",
  "contributes": {
    "viewsContainers": {
      "activitybar": [
        {
          "id": "agentpulse",
          "title": "Vibe Signal",
          "icon": "media/pulse.svg"
        }
      ]
    },
    "views": {
      "agentpulse": [
        {
          "type": "webview",
          "id": "agentpulse.sidebar",
          "name": "Connector",
          "icon": "media/pulse.svg"
        }
      ]
    },
    "commands": [
      {
        "command": "agentpulse.toggle",
        "title": "Vibe Signal: Toggle Connector"
      },
      {
        "command": "agentpulse.enable",
        "title": "Vibe Signal: Enable Connector"
      },
      {
        "command": "agentpulse.disable",
        "title": "Vibe Signal: Disable Connector"
      },
      {
        "command": "agentpulse.focusSidebar",
        "title": "Vibe Signal: Open Sidebar"
      },
      {
        "command": "agentpulse.showSetupGuide",
        "title": "Vibe Signal: Show Setup Guide"
      },
      {
        "command": "agentpulse.markHooksTrusted",
        "title": "Vibe Signal: Mark Hooks Trusted"
      },
      {
        "command": "agentpulse.pairDevice",
        "title": "Vibe Signal: Pair Device"
      },
      {
        "command": "agentpulse.setupHooks",
        "title": "Vibe Signal: Setup Codex Hooks"
      },
      {
        "command": "agentpulse.simulateEvent",
        "title": "Vibe Signal: Simulate Event"
      },
      {
        "command": "agentpulse.showStatus",
        "title": "Vibe Signal: Show Status"
      },
      {
        "command": "agentpulse.copyPairingInfo",
        "title": "Vibe Signal: Copy Pairing Info"
      },
      {
        "command": "agentpulse.rotateToken",
        "title": "Vibe Signal: Rotate Token"
      }
    ],
    "configuration": {
      "title": "Vibe Signal",
      "properties": {
        "agentpulse.port": {
          "type": "number",
          "default": 8787,
          "description": "LAN WebSocket / local HTTP port."
        },
        "agentpulse.permissionTimeoutMs": {
          "type": "number",
          "default": 120000,
          "description": "How long hooks wait for Approve/Deny from the phone."
        },
        "agentpulse.stopTimeoutMs": {
          "type": "number",
          "default": 300000,
          "description": "How long hooks wait for Continue/voice after a turn completes."
        },
        "agentpulse.workingTimeoutMs": {
          "type": "number",
          "default": 600000,
          "description": "Reset to Idle when no agent activity or Stop hook arrives within this time."
        }
      }
    }
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "test": "npm run compile && node --test test/*.test.js",
    "watch": "tsc -watch -p ./",
    "package": "vsce package"
  },
  "dependencies": {
    "ws": "^8.18.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.0",
    "@types/vscode": "^1.85.0",
    "@types/ws": "^8.5.10",
    "typescript": "^5.3.3"
  }
}

```

### connector/src/server.ts

```typescript
import * as http from "http";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "http";
import { URL } from "url";
import type { StateMachine } from "./state";
import type { DecisionHub } from "./decisionHub";
import type {
  AckMessage,
  AgentCommand,
  CommandMessage,
  StateSnapshot,
} from "./types";

export interface ConnectorServerOptions {
  port: number;
  token: string;
  state: StateMachine;
  decisions: DecisionHub;
  permissionTimeoutMs: number;
  stopTimeoutMs: number;
  onClientCountChange?: (count: number) => void;
  onStartTurn?: (request: StartTurnRequest) => Promise<boolean>;
  /** Kill the phone-launched Codex terminal / process. */
  onStopTurn?: () => boolean | Promise<boolean>;
}

export interface StartTurnRequest {
  prompt: string;
  sessionId?: string;
  cwd?: string;
}

export class ConnectorServer {
  private httpServer: http.Server;
  private wss: WebSocketServer;
  private clients = new Set<WebSocket>();
  private pingTimer?: NodeJS.Timeout;
  readonly port: number;
  readonly token: string;
  private readonly state: StateMachine;
  private readonly decisions: DecisionHub;
  private readonly permissionTimeoutMs: number;
  private readonly stopTimeoutMs: number;
  private readonly onClientCountChange?: (count: number) => void;
  private readonly onStartTurn?: (request: StartTurnRequest) => Promise<boolean>;
  private readonly onStopTurn?: () => boolean | Promise<boolean>;

  constructor(opts: ConnectorServerOptions) {
    this.port = opts.port;
    this.token = opts.token;
    this.state = opts.state;
    this.decisions = opts.decisions;
    this.permissionTimeoutMs = opts.permissionTimeoutMs;
    this.stopTimeoutMs = opts.stopTimeoutMs;
    this.onClientCountChange = opts.onClientCountChange;
    this.onStartTurn = opts.onStartTurn;
    this.onStopTurn = opts.onStopTurn;

    this.httpServer = http.createServer((req, res) => {
      void this.handleHttp(req, res);
    });

    this.wss = new WebSocketServer({ noServer: true });

    this.httpServer.on("upgrade", (req, socket, head) => {
      const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
      const token = url.searchParams.get("token");
      if (token !== this.token) {
        socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
        socket.destroy();
        return;
      }
      this.wss.handleUpgrade(req, socket, head, (ws) => {
        this.wss.emit("connection", ws, req);
      });
    });

    this.wss.on("connection", (ws) => this.onWsConnection(ws));

    this.state.on("change", (snap: StateSnapshot) => {
      this.broadcast(snap);
    });
  }

  async start(): Promise<void> {
    await new Promise<void>((resolve, reject) => {
      this.httpServer.once("error", reject);
      this.httpServer.listen(this.port, "0.0.0.0", () => resolve());
    });
    this.pingTimer = setInterval(() => {
      const ping = JSON.stringify({ type: "ping", ts: Date.now() });
      for (const ws of this.clients) {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(ping);
        }
      }
    }, 25_000);
  }

  async stop(): Promise<void> {
    if (this.pingTimer) clearInterval(this.pingTimer);
    this.decisions.clear();
    for (const ws of this.clients) {
      ws.close();
    }
    this.clients.clear();
    await new Promise<void>((resolve) => this.wss.close(() => resolve()));
    await new Promise<void>((resolve, reject) => {
      this.httpServer.close((err) => (err ? reject(err) : resolve()));
    });
  }

  clientCount(): number {
    return this.clients.size;
  }

  broadcast(message: object): void {
    const raw = JSON.stringify(message);
    for (const ws of this.clients) {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(raw);
      }
    }
  }

  async handleCommand(cmd: CommandMessage): Promise<AckMessage> {
    switch (cmd.command) {
      case "approve": {
        const ok = this.decisions.resolvePermission({ decision: "allow" });
        if (ok) this.resumeAfterDecision("Approved");
        return this.decisionAck("approve", ok);
      }
      case "deny": {
        const ok = this.decisions.resolvePermission({
          decision: "deny",
          message: cmd.text ?? "Denied from Vibe Signal",
        });
        if (ok) this.resumeAfterDecision("Denied");
        return this.decisionAck("deny", ok);
      }
      case "continue": {
        const reason = cmd.text?.trim() || "Continue.";
        const ok = this.decisions.resolveStop({
          decision: "continue",
          reason,
        });
        if (ok) this.resumeAfterDecision(reason);
        return this.decisionAck("continue", ok);
      }
      case "retry": {
        const reason = cmd.text?.trim() || "Retry.";
        const ok = this.decisions.resolveStop({
          decision: "continue",
          reason,
        });
        if (ok) this.resumeAfterDecision(reason);
        return this.decisionAck("retry", ok);
      }
      case "voice_prompt": {
        const text = cmd.text?.trim();
        if (!text) {
          return {
            type: "ack",
            command: "voice_prompt",
            ok: false,
            message: "Missing text",
          };
        }
        // Prefer injecting into the one paused turn (Stop hook long-polling).
        if (this.decisions.resolveStop({ decision: "continue", reason: text })) {
          this.resumeAfterDecision(text);
          return { type: "ack", command: "voice_prompt", ok: true };
        }
        // One thread only: never spawn while Codex is mid-turn.
        const current = this.state.get();
        const canStartTurn =
          current.state === "idle" ||
          current.state === "completed" ||
          current.state === "error";
        if (this.onStartTurn && canStartTurn) {
          const started = await this.onStartTurn({
            prompt: text,
            sessionId: current.sessionId,
            cwd: current.cwd,
          });
          if (started) {
           
[truncated — 6655 more characters]
```

### app/project.yml

```yaml
name: VibeSignal
options:
  bundleIdPrefix: com.vibesignal
  deploymentTarget:
    iOS: "17.0"
    watchOS: "10.0"
  createIntermediateGroups: true
settings:
  SWIFT_VERSION: "5.9"
  DEVELOPMENT_TEAM: ""
  # Watch Info.plist reads this — must match the iOS app's PRODUCT_BUNDLE_IDENTIFIER.
  VIBESIGNAL_IOS_BUNDLE_ID: com.vibesignal.app
targets:
  VibeSignal:
    type: application
    platform: iOS
    sources:
      - path: iOS
      - path: Shared
    settings:
      PRODUCT_BUNDLE_IDENTIFIER: com.vibesignal.app
      PRODUCT_NAME: VibeSignal
      INFOPLIST_FILE: iOS/Info.plist
      TARGETED_DEVICE_FAMILY: "1"
      CODE_SIGN_ENTITLEMENTS: iOS/AgentPulse.entitlements
      ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
      # Shown under the icon; keep executable / PRODUCT_NAME free of spaces.
      INFOPLIST_KEY_CFBundleDisplayName: Vibe Signal
    dependencies:
      - target: VibeSignalWatch
        embed: true
    scheme:
      testTargets: []

  VibeSignalWatch:
    type: application
    platform: watchOS
    sources:
      - path: Watch
      - path: Shared
        excludes:
          - AgentPulseClient.swift
    settings:
      PRODUCT_BUNDLE_IDENTIFIER: com.vibesignal.app.watchkitapp
      PRODUCT_NAME: VibeSignalWatch
      INFOPLIST_FILE: Watch/Info.plist
      SDKROOT: watchos
      TARGETED_DEVICE_FAMILY: "4"
      SKIP_INSTALL: YES
      ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
      INFOPLIST_KEY_CFBundleDisplayName: Vibe Signal
      # Companion ID comes from Watch/Info.plist → $(VIBESIGNAL_IOS_BUNDLE_ID)

```

### demo/index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Vibe Signal | Demo</title>
  <style>
    :root { --ink:#111315; --paper:#e9e5dc; --cream:#f5f1e8; --orange:#f15b2a; --muted:#aaa69d; --line:rgba(17,19,21,.18); }
    * { box-sizing:border-box; } body { margin:0; min-height:100dvh; background:var(--paper); color:var(--ink); font-family:Arial, Helvetica, sans-serif; overflow:hidden; }
    .grain { position:fixed; inset:0; pointer-events:none; opacity:.17; background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E"); mix-blend-mode:multiply; }
    .deck { height:100dvh; position:relative; } .slide { position:absolute; inset:0; padding:clamp(26px,5vw,72px); display:none; grid-template-rows:auto 1fr auto; } .slide.active { display:grid; animation:in .45s both; } @keyframes in { from { opacity:0; transform:translateY(15px) } to { opacity:1; transform:none } }
    .topline { display:flex; justify-content:space-between; align-items:center; font:700 11px/1.1 "Courier New", monospace; letter-spacing:.12em; text-transform:uppercase; } .brand { display:flex; gap:8px; align-items:center; } .brand i { width:10px; height:10px; border-radius:50%; background:var(--orange); box-shadow:0 0 0 3px rgba(241,91,42,.18); } .time { color:#65635e; }
    .bottom { display:flex; align-items:center; justify-content:space-between; font:700 11px "Courier New",monospace; letter-spacing:.1em; } .progress { width:min(210px,32vw); height:3px; background:rgba(17,19,21,.15); } .progress b { display:block; height:100%; background:var(--orange); transition:width .3s; } .keys { color:#68645e; }
    h1,h2,p { margin:0; } h1 { max-width:980px; font-size:clamp(51px,8vw,124px); line-height:.87; letter-spacing:-.075em; font-weight:800; } h2 { font-size:clamp(35px,5vw,72px); line-height:.92; letter-spacing:-.06em; max-width:780px; } .orange { color:var(--orange); } .intro { align-self:center; } .intro p { max-width:510px; margin-top:26px; font:600 clamp(16px,1.8vw,23px)/1.35 "Courier New",monospace; }
    .stamp { position:absolute; right:7vw; bottom:11vh; width:clamp(165px,22vw,300px); aspect-ratio:1; border:2px solid var(--orange); color:var(--orange); border-radius:50%; display:grid; place-items:center; text-align:center; font:800 clamp(20px,3vw,42px)/.9 Arial; transform:rotate(11deg); }
    .statement { display:grid; align-content:center; gap:30px; } .statement .line { width:84px; height:9px; background:var(--orange); } .statement p { max-width:590px; font:600 clamp(16px,2vw,23px)/1.35 "Courier New",monospace; }
    .phone-layout { display:grid; grid-template-columns:1.1fr .9fr; align-items:center; gap:7vw; } .phone { width:min(100%,390px); aspect-ratio:.74; border:15px solid var(--ink); border-radius:36px; background:var(--cream); padding:18px; justify-self:end; box-shadow:16px 16px 0 var(--orange); display:grid; grid-template-rows:auto 1fr auto; } .phone-top { display:flex; justify-content:space-between; font:700 10px "Courier New"; } .screen-copy { align-self:center; } .screen-copy strong { display:block; font-size:42px; letter-spacing:-.08em; line-height:.9; } .screen-copy span { display:block; margin-top:15px; font:700 12px/1.4 "Courier New"; } .qr { width:105px; aspect-ratio:1; background:repeating-conic-gradient(#111 0 25%,#eee 0 50%) 50%/20px 20px; border:8px solid #111; justify-self:end; }
    .slide:nth-of-type(3) .phone-layout { grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr); gap:clamp(30px,4vw,58px); }
    .slide:nth-of-type(3) .phone { width:min(100%,500px); }
    .signal-flow { margin-top:clamp(30px,5vh,52px); padding:clamp(14px,1.1vw,22px); background:var(--ink); color:var(--cream); box-shadow:clamp(10px,.8vw,16px) clamp(10px,.8vw,16px) 0 var(--orange); width:100%; max-width:980px; }
    .signal-flow-head { display:flex; justify-content:space-between; align-items:center; padding:4px 5px clamp(14px,1.1vw,22px); font:700 clamp(11px,.78vw,15px)/1 "Courier New",monospace; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
    .signal-flow-head b { color:var(--orange); font-weight:700; }
    .signal-track { display:grid; grid-template-columns:repeat(4,minmax(0,1fr) clamp(16px,1.25vw,24px)) minmax(0,1fr); align-items:stretch; }
    .signal-node { min-width:0; min-height:clamp(62px,5.2vw,100px); padding:clamp(13px,1.15vw,22px) 6px; background:rgba(255,255,255,.07); text-align:center; display:grid; gap:clamp(9px,.7vw,14px); align-content:center; }
    .signal-node small { color:var(--orange); font:700 clamp(9px,.63vw,12px)/1 "Courier New",monospace; letter-spacing:.08em; }
    .signal-node strong { font:700 clamp(10px,.94vw,18px)/1 "Courier New",monospace; letter-spacing:-.03em; white-space:nowrap; }
    .signal-arrow { position:relative; min-width:0; }
    .signal-arrow::before { content:""; position:absolute; left:2px; right:3px; top:50%; height:1px; background:var(--orange); }
    .signal-arrow::after { content:""; position:absolute; right:1px; top:calc(50% - 3px); width:5px; height:5px; border-top:1px solid var(--orange); border-right:1px solid var(--orange); transform:rotate(45deg); }
    .demo-grid { display:grid; grid-template-columns:1fr 310px; gap:8vw; align-items:center; } .script { max-width:700px; } .script .cue { font:700 12px "Courier New"; letter-spacing:.13em; color:var(--orange); text-transform:uppercase; margin-bottom:20px; } .script p { margin-top:20px; font-size:clamp(17px,2vw,25px); font-weight:600; line-height:1.25; max-width:600px; }
    /* Page 4: Desktop | Text / Watch — scales with viewport */
    .slide:nth-of-type(4) {
      row-gap: clamp(18px, 2.8vh, 34px);
    }
    .live-stage {
      display: grid;
      gr
[truncated — 14279 more characters]
```

### app/Shared/Theme.swift

```swift
import SwiftUI

// PulseTheme / AgentState.pulseLabel live in Models.swift so older Xcode
// projects that already compile Shared/Models.swift always see them.
// This file stays so any leftover project reference to Theme.swift still builds.

```

### app/Watch/AgentPulseWatchApp.swift

```swift
import SwiftUI

@main
struct AgentPulseWatchApp: App {
    @StateObject private var model = WatchModel()

    var body: some Scene {
        WindowGroup {
            WatchStatusView()
                .environmentObject(model)
                .onAppear { model.start() }
        }
    }
}

```

### connector/src/network.ts

```typescript
import * as os from "os";
import * as crypto from "crypto";

export function generateToken(): string {
  return crypto.randomBytes(24).toString("base64url");
}

export function getLanAddresses(): string[] {
  const nets = os.networkInterfaces();
  const addrs: string[] = [];
  for (const entries of Object.values(nets)) {
    if (!entries) continue;
    for (const e of entries) {
      if (e.family === "IPv4" && !e.internal) {
        addrs.push(e.address);
      }
    }
  }
  return addrs;
}

export function preferredLanAddress(): string {
  const addrs = getLanAddresses();
  return addrs[0] ?? "127.0.0.1";
}

```

### app/iOS/AgentPulseApp.swift

```swift
import SwiftUI

@main
struct AgentPulseApp: App {
    @StateObject private var appModel = AppModel()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(appModel)
                .onAppear {
                    appModel.start()
                }
        }
        .onChange(of: scenePhase) { _, phase in
            switch phase {
            case .active:
                appModel.handleBecameActive()
            case .inactive, .background:
                appModel.handleEnteringBackground()
            @unknown default:
                break
            }
        }
    }
}

```

[29 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]