Skip to content

feat: Extension system, Remote Gateway, macOS Desktop App, and comprehensive documentation - #2

Merged
roodboi merged 22 commits into
mainfrom
extension-system
Jan 21, 2026
Merged

feat: Extension system, Remote Gateway, macOS Desktop App, and comprehensive documentation#2
roodboi merged 22 commits into
mainfrom
extension-system

Conversation

@roodboi

@roodboi roodboi commented Jan 6, 2026

Copy link
Copy Markdown
Contributor

Overview

This is a major release that introduces a modular extension system, remote access capabilities, a native macOS desktop app, and comprehensive documentation. The release transforms hack from a local development tool into a full-featured platform for managing containerized development environments locally and remotely.

Key Features

🧩 Extension System

A plugin architecture that allows extending hack CLI with new capabilities:

  • Extension Manager - Load, register, and dispatch commands to extensions
  • CLI Dispatcher (hack x <extension> <command>) - Run extension commands
  • Built-in Extensions:
    • gateway - Remote HTTP/WebSocket access
    • supervisor - Background job management
    • tickets - Git-backed issue tracking
    • cloudflare - Tunnel exposures via Cloudflare
    • tailscale - Mesh network exposures

🌐 Remote Gateway

Secure remote access to hack-managed projects:

  • HTTP/WebSocket server with token-based authentication
  • PTY-backed shell streaming for remote terminal access
  • Multiple exposure methods (Cloudflare tunnels, Tailscale, SSH)
  • Audit logging and token management
  • SDK client for programmatic access

📋 Supervisor

Background job and process management:

  • Job store with persistent state
  • Process runner with lifecycle management
  • Shell service for interactive sessions
  • Event logging and combined output streams

🎫 Tickets

Git-backed issue tracking that lives with your code:

  • JSONL event log stored in hidden git refs
  • Full CLI for create/list/show/update/status
  • Sync to remote repositories
  • Agent-friendly documentation generation

🖥️ macOS Desktop App

Native SwiftUI application (Hack Desktop):

  • Dashboard view for all hack projects and services
  • Real-time status monitoring
  • Integrated logs viewer
  • Menu bar quick access
  • launchd integration - Run hackd daemon at login
  • Code signed and notarized for distribution

🚀 Release Tooling

Unified build and release process:

  • bun run macos:release - Build CLI + macOS app → signed/notarized DMG
  • Version sync between package.json and macOS app
  • GitHub Actions workflows for CI releases
  • Combined DMG with app + CLI installer

📚 Documentation

Comprehensive docs covering:

New Commands

# Extensions
hack x <extension> <command>     # Run extension command
hack x tickets create            # Create a ticket
hack x tickets list              # List tickets

# Gateway
hack gateway start               # Start gateway server
hack gateway status              # Check gateway status
hack gateway token create        # Create access token

# Remote
hack remote shell                # Open remote shell
hack remote expose               # Expose project remotely

# Daemon
hack daemon start                # Start hackd daemon
hack daemon install              # Install as launchd service
hack daemon status               # Check daemon status

# Config
hack config get <path>           # Get config value
hack config set <path> <value>   # Set config value

Breaking Changes

None - this release is additive.

Testing

  • 237 files changed, 36,159 insertions
  • New test suites for gateway, supervisor, tickets, daemon, and extensions
  • E2E tests for gateway authentication and WebSocket streaming

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Gateway API for remote project control and management
    • Introduced Supervisor for running jobs and interactive shells
    • Added Extensions framework with built-in support for Tickets, Cloudflare, Tailscale
    • Released macOS Desktop application with dashboard and terminal
    • Added comprehensive remote access and control workflows
    • Introduced Tickets extension for git-backed issue tracking
  • Documentation

    • Added CLI reference documentation
    • Added Architecture and SDK guides
    • Added Gateway setup and usage guides (SSH, Cloudflare, Tailscale)
    • Added Extension creation guide
  • CLI

    • New usage command for resource monitoring
    • New gateway command for remote access setup
    • New remote command for remote workflows
    • New x command for extension dispatch

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot

changeset-bot Bot commented Jan 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9ac84cb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jan 6, 2026

Copy link
Copy Markdown

Walkthrough

Adds a control plane with an extensible extensions system (gateway, supervisor, cloudflare, tailscale, tickets), gateway HTTP/WS API with token auth and audit logging, supervisor job/shell services and persistence, CLI commands and daemon/launchd integration, extensive docs/examples, Next.js gateway shell UI, macOS app scaffold, and tests. (50 words)

Changes

Cohort / File(s) Summary
Docs & Guides
README.md, docs/*, docs/guides/*
Large documentation additions and README rework: architecture, CLI reference, gateway API, extensions, SDK, supervisor, remote exposure guides, tickets guide, examples and how‑tos.
CLI Core & Spec
src/cli/*, src/cli/spec.ts
CLI spec updates: added Extensions/Internal groups, allowUnknownOptions opt-in, help ordering, and new top-level commands wired (usage, gateway, remote, internal, x).
New CLI Commands & Options
src/commands/*
New/expanded commands: gateway (enable/setup/disable, tokens, exposure flows), remote (status/monitor/qr), usage, x (extension dispatcher), internal (extra-hosts), plus config global flag support, doctor gateway checks, global gateway status, daemon install/restart/clear.
Control Plane Framework
src/control-plane/extensions/*, src/control-plane/extensions/types.ts, src/control-plane/extensions/manager.ts
Extension types, ExtensionManager, builtins registration, CLI/daemon loaders, and multiple extension modules added (gateway, supervisor, cloudflare, tailscale, tickets).
Supervisor Subsystem
src/control-plane/extensions/supervisor/*
File-based JobStore, runner, SupervisorService, PTY-backed ShellService, supervisor commands, job event streaming and JSONL examples in examples/basic/.hack/supervisor/*.
Gateway Subsystem & SDK
src/control-plane/extensions/gateway/*, src/control-plane/sdk/gateway-client.ts, examples/basic/gateway-demo.ts
Gateway token store (create/list/revoke/verify), authentication, audit logging, gateway API handlers (HTTP+WS), typed gateway SDK client, and example/demo script and Next.js UI integration.
Control‑plane Config & SDK
src/control-plane/sdk/config.ts, src/control-plane/sdk/*
Zod-based control plane schema, layered global/project merge, global config path, extension merge rules, and SDK docs.
Daemon Integration & Server
src/daemon/*, src/daemon/server.ts
Logger refactor to generic Logger, daemon client POST/body support, daemon server loads extensions/services, starts gateway server, and exposes control‑plane endpoints and WebSocket streaming.
Tickets Subsystem
src/control-plane/extensions/tickets/*, examples/tickets/*
Git-backed event‑sourced tickets store, git channel, repo utilities, CLI commands, skill/docs helpers, example project and docs.
TUI / UI & Helpers
src/tui/hack-tui.ts, src/ui/*, src/types/*
TUI refactor (sidebar, resources, logs, stats), QR and clipboard utilities and tests, lipgloss types, QR rendering, and UI helpers.
Examples & Frontend
examples/basic/*, examples/next-app/*
Gateway demo scripts, supervisor logs, Next.js gateway shell UI, server proxy route, styles, turbopack config, xterm integration and dependencies.
macOS App & Tooling
apps/macos/**, scripts/macos-*, package.json
New macOS app scaffold (Swift/SwiftUI), Ghostty Zig bridge, Swift package, UI components, build/release workflows, setup scripts, and package.json macOS scripts/deps.
Utilities & Infra
src/lib/*, src/constants.ts, src/templates.ts
New constants, resolveGlobalConfigPath, Caddy/extra_hosts utilities, extraHosts in templates, ProjectView projectId, registry-by-id helper.
Tests & Scripts
tests/*, scripts/*, package.json
New unit tests (extensions, control-plane config, clipboard, cloudflare parsers), gateway e2e runner script, utility scripts, added qrcode dep and e2e test script.
Misc / Cleanup & Examples
.gitignore, .beads/*, .hack/*, examples/*
Removed .beads docs/data, updated .gitignore, added many .hack example configs (internal compose overrides), assets, and README fragments.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI (hack)
  participant Gateway as Gateway (hackd gateway)
  participant Daemon as Daemon (hackd)
  participant Supervisor as SupervisorService
  participant JobStore as JobStore (file)

  CLI->>Gateway: POST /v1/control-plane/projects/:id/jobs (Bearer token)
  Gateway->>Gateway: authenticate token, append audit entry
  Gateway->>Daemon: forward create job request
  Daemon->>Supervisor: createJob(projectDir, command)
  Supervisor->>JobStore: create meta.json + append job.created event
  Supervisor->>Supervisor: spawn process, stream stdout/stderr
  Supervisor->>JobStore: append job.started / stdout lines / job.completed
  CLI->>Gateway: open WS /v1/control-plane/.../jobs/:jobId/stream
  Gateway->>Daemon: attach to job stream
  Daemon->>Gateway: relay events/logs
  Gateway->>CLI: forward WS events to client
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

"I nibbled through code by moonlight's gleam,
Tokens, shells, and queues in a rabbit dream,
Extensions bloom and gateway lights hum,
Job stores whisper, audits drum,
A tiny hop — new features have come! 🐇✨"

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: Extension system, Remote Gateway, macOS Desktop App, and comprehensive documentation' directly and comprehensively summarizes the main changes in the changeset, clearly identifying all major feature additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🤖 Fix all issues with AI Agents
In @docs/cli.md:
- Around line 1-862: Several Markdown tables have mismatched column counts
causing lint failures; inspect and correct the tables so the header row and the
divider (---) define the same number of columns as every data row. Specifically,
fix the "Top-level commands" table and the option/argument tables in the
sections for "hack global logs", "hack logs", "hack remote qr" and the later
MCP/Options tables (these are the tables the linter flagged); ensure each
table's header cells, the separating --- row, and every subsequent row have
identical pipe-separated column counts, removing or merging extra columns or
expanding the header as appropriate so rendering and the linter pass.

In @docs/guides/remote-tailscale.md:
- Around line 34-39: Add a security note explaining that binding the gateway to
0.0.0.0 (the config key controlPlane.gateway.bind) exposes the gateway on all
network interfaces and is less secure than using tailscale serve (Option A);
instruct readers to prefer Option A for isolation or, if using Option B, to
restrict access via firewall rules and explicitly state the difference in
security posture between the two options.

In @docs/sdk.md:
- Line 10: Documentation examples use incorrect relative imports for
createGatewayClient and config; update the import statements in docs/sdk.md to
reference the actual SDK module locations (e.g., import createGatewayClient from
"src/control-plane/sdk/gateway-client.ts" and import config from
"src/control-plane/sdk/config.ts"), or alternatively add a short note explaining
that users should copy the SDK files into their project and then import from the
local paths (e.g., "./gateway-client.ts" after copying); change all examples
referencing "./gateway-client.ts" and "./config.ts" to one of these accurate
options so the docs run as-is for readers.

In @docs/supervisor.md:
- Around line 36-38: Update the Markdown code fence for the WebSocket URL so it
includes a language identifier (e.g., change the opening ``` to ```text) around
the block containing
"ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream" to
match the project's code block conventions and silence markdownlint warnings.

In @examples/basic/gateway-demo.ts:
- Around line 232-243: safeJsonParse currently treats any non-null object as a
Record, which mistakenly includes arrays; update the function (safeJsonParse) to
explicitly reject arrays by adding a check such as Array.isArray(parsed) and
only return the parsed value when it's an object, not null, and not an array, so
the return stays Record<string, unknown> | null; keep the empty-string guard and
try/catch behavior unchanged.
- Around line 81-99: printStatus and printProjects currently call fetch without
handling network errors; wrap the fetch/response parsing in a try-catch inside
each function (printStatus and printProjects), catch any exceptions from fetch
or res.text(), and handle them by printing a clear error message (e.g., to
process.stderr or process.stdout) and returning early so the script doesn’t
crash; ensure you still use opts.headers and opts.baseUrl and preserve existing
behavior on success.

In @examples/next-app/app/api/gateway/route.ts:
- Around line 13-31: The POST proxy reads and forwards arbitrary baseUrl and
buffers the entire upstream response into memory (SSRF and memory risks); update
the POST handler to validate/whitelist/sanitize baseUrl before calling
buildUrl/fetch (add an isAllowedBaseUrl helper that enforces schemes like
https:, blocks private IPs and metadata addresses such as 169.254.169.254, or
checks against an explicit host whitelist), and replace the uncontrolled
response.text() buffering with a bounded/streaming approach (do not call
response.text(); instead stream response.body to the client or enforce a max
byte limit with an AbortController or byte-counter and return 413 if exceeded).
Ensure you reference and plug validation into the existing
parseBody/readJsonBody/buildUrl/fetch flow in POST.
- Around line 107-109: The isRecord type guard currently returns true for arrays
because arrays are typeof "object"; update isRecord to also exclude arrays by
checking !Array.isArray(value). Replace its body with: return typeof value ===
"object" && value !== null && !Array.isArray(value) so only plain objects
satisfy the guard.

In @examples/next-app/app/gateway/page.tsx:
- Around line 389-391: The local isRecord function incorrectly treats arrays as
records; update the isRecord implementation (function isRecord) to also exclude
arrays by adding a check like !Array.isArray(value) so it returns true only for
non-null plain objects, matching the canonical guard in src/lib/guards.ts.

In @examples/next-app/next.config.ts:
- Line 4: CI type-check is failing because the examples/next-app depends on
Next.js types that aren't installed; either ensure the example's deps are
installed in CI (add a step to run "cd examples/next-app && bun install" or the
equivalent npm/yarn install before running type-check), or exclude the
examples/next-app from root TypeScript checks by updating root tsconfig.json to
remove or add "examples/**" to "exclude", or add a local tsconfig.json in
examples/next-app to opt out; update CI config to implement one of these fixes
and verify by running a full type-check after the change.

In @README.md:
- Around line 147-156: The fenced code block containing the lines "Run `hack
help` (or `hack help <command>`) for full usage." and the following command list
should either have a language identifier added (change the opening ``` to
```bash) to enable syntax highlighting, or be converted to a plain bullet list
if intended as regular prose; locate the code fence that wraps the "hack help"
and "Common:" lines and update the opening fence to "```bash" or replace the
fenced block with standard Markdown list items to match the surrounding section.

In @src/commands/config.ts:
- Around line 331-406: In parseKeyPath rename the local boolean variable escape
to a non-shadowing name (e.g. isEscaped) and update every reference/assignment
(checks, setting true/false, and the final if (escape) branch) so behavior is
unchanged; ensure the helper pushBuffer and all conditional logic in the
inBracket and non-bracket branches use the new identifier so no functionality or
control flow is altered.

In @src/control-plane/extensions/manager.ts:
- Around line 168-223: sanitizeNamespace currently calls raw.trim() and will
throw when resolveNamespace passes undefined for override/preferred; make the
namespace sanitization robust by either changing sanitizeNamespace to accept
string | undefined and coerce with (raw ?? "").trim().toLowerCase() or update
the call site in resolveNamespace to pass a safe default
(sanitizeNamespace(override ?? preferred ?? "")). Update only sanitizeNamespace
or the call in resolveNamespace (referencing sanitizeNamespace and
resolveNamespace) so undefined cliNamespace values no longer cause runtime
errors and fallback logic can run.

In @src/control-plane/extensions/supervisor/service.ts:
- Around line 109-121: The catch block for runJob can append a duplicate
"job.failed" event if runJob already recorded failure then threw; modify the
catch handler (the Promise.catch that logs error, calls store.updateJobStatus
and store.appendEvent) to first read the current job status (e.g., via
store.getJob or store.getJobStatus) and only call store.appendEvent({ type:
"job.failed", ... }) if the status is not already "failed" (leave
updateJobStatus as-is or make it idempotent). Alternatively, have runJob return
a sentinel or set a flag when it has already recorded failure and check that
flag here before appending; ensure runningJobs.delete(jobId) remains in the
finally block.

In @src/control-plane/extensions/supervisor/shell-service.ts:
- Around line 260-266: The code currently overwrites any user-provided TERM
because env["TERM"] is always set after merging opts.env; update the logic in
the shell-service merging block (the env/opts.env handling) to only set TERM
when not already provided by the merged env — e.g., after merging opts.env into
env, check if env.TERM is undefined/null and then assign process.env.TERM ??
DEFAULT_TERM — referencing the env variable, opts.env, and DEFAULT_TERM in your
change.

In @src/lib/caddy-hosts.ts:
- Around line 82-99: extractExtraHostsIp currently returns raw values from the
record branch without IPv4 validation; update the record handling in
extractExtraHostsIp to validate each value (use existing helpers
parseExtraHostEntry or isIpv4) before returning, e.g., iterate
Object.values(extraHosts), coerce non-strings, run parseExtraHostEntry(entry) or
isIpv4(value) and return the first valid IPv4 string, otherwise continue and
finally return null; keep the Array.isArray branch behavior unchanged and reuse
parseExtraHostEntry/isIpv4 to ensure consistent validation.
🧹 Nitpick comments (41)
src/mcp/install.ts (1)

261-261: Unnecessary helper adds verbosity without benefit.

The createOkParseResult helper is used only once and makes line 261 more verbose than the original inline object literal. The helper itself is 7 lines of type declarations wrapping a 1-line return statement, which adds ceremony without improving reusability or clarity.

🔎 Simplify by removing the helper
-  const parsed = text ? parseJsonObject(text) : createOkParseResult({ value: {} })
+  const parsed = text ? parseJsonObject(text) : { ok: true, value: {} }

And remove the helper function:

-function createOkParseResult(opts: {
-  readonly value: Record<string, unknown>
-}): {
-  readonly ok: true
-  readonly value: Record<string, unknown>
-} {
-  return { ok: true, value: opts.value }
-}
-
 function resolveHomeDir(): string | null {

Also applies to: 416-423

src/templates.ts (1)

360-438: Add validation constraints for numeric and string fields.

The new controlPlane schema lacks validation constraints that would catch invalid configurations at load time. Consider adding:

  • Port range: port should be between 1-65535
  • Positive integers: maxConcurrentJobs, logsMaxBytes, maxEntries, maxLines, historyTailStep, watchIntervalMs, historySize should have minimum: 1 or minimum: 0 constraints
  • Bind address format: bind could use a pattern or format constraint to validate IP addresses or hostnames
🔎 Example validation constraints
           gateway: {
             type: "object",
             additionalProperties: true,
             properties: {
               enabled: { type: "boolean" },
-              bind: { type: "string" },
+              bind: { 
+                type: "string",
+                pattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}$|^[a-zA-Z0-9.-]+$"
+              },
-              port: { type: "number" },
+              port: { type: "number", minimum: 1, maximum: 65535 },
               allowWrites: { type: "boolean" }
             }
           },
           supervisor: {
             type: "object",
             additionalProperties: true,
             properties: {
               enabled: { type: "boolean" },
-              maxConcurrentJobs: { type: "number" },
+              maxConcurrentJobs: { type: "number", minimum: 1 },
-              logsMaxBytes: { type: "number" }
+              logsMaxBytes: { type: "number", minimum: 0 }
             }
           },
           usage: {
             type: "object",
             additionalProperties: true,
             properties: {
-              watchIntervalMs: { type: "number" },
+              watchIntervalMs: { type: "number", minimum: 100 },
-              historySize: { type: "number" }
+              historySize: { type: "number", minimum: 1 }
             }
           }

Apply similar constraints to tui.logs fields (maxEntries, maxLines, historyTailStep).

src/control-plane/extensions/supervisor/shell-service.ts (3)

104-108: Consider wrapping listener callbacks in try-catch.

If a listener's onData callback throws an exception, it will interrupt data delivery to subsequent listeners in the Set. This could cause some clients to miss data if another client's handler fails.

🔎 Proposed fix
         data: (_term, data) => {
           for (const listener of listeners) {
-            listener.onData(data)
+            try {
+              listener.onData(data)
+            } catch {
+              // Prevent one listener from breaking others
+            }
           }
         },

203-227: Operations on exited shells may fail silently or throw.

The write, resize, signal, and close methods don't check if the shell has already exited. Depending on Bun's Terminal/process behavior, these operations could throw or be silently ignored when the underlying process is no longer running.

🔎 Proposed guard
     return {
       meta: session.meta,
       write: data => {
+        if (session.meta.status === "exited") return
         session.terminal.write(data)
         touchShell(session)
       },
       resize: (cols, rows) => {
+        if (session.meta.status === "exited") return
         session.terminal.resize(cols, rows)
         session.meta = {
           ...session.meta,
           cols,
           rows,
           updatedAt: new Date().toISOString()
         }
       },
       signal: signal => {
+        if (session.meta.status === "exited") return
         session.proc.kill(signal)
         touchShell(session)
       },
       close: () => {
+        if (session.meta.status === "exited") return
         session.proc.kill()
         touchShell(session)
       },
       detach
     }

291-293: Wrap onExit callbacks in try-catch for resilience.

Same concern as with onData: if one listener's onExit callback throws, remaining listeners won't receive the exit notification.

🔎 Proposed fix
   for (const listener of opts.session.listeners) {
-    listener.onExit(opts.exitCode, null)
+    try {
+      listener.onExit(opts.exitCode, null)
+    } catch {
+      // Prevent one listener from breaking others
+    }
   }
src/tui/hack-tui.ts (1)

689-736: Large mutable state surface area.

This segment declares ~50 mutable state variables. While acceptable for a TUI application, consider grouping related state into objects (e.g., searchState, statsState, uiState) to improve maintainability and reduce the risk of inconsistent updates.

docs/architecture.md (1)

152-154: Fix spacing before heading (minor formatting).

Line 154 has excess whitespace before the heading. This is a minor formatting issue.

🔎 Proposed fix
-Note: the daemon does not proxy logs yet; `hack logs` still talks directly to Docker Compose or Loki.  ## Daemon (hackd)
+Note: the daemon does not proxy logs yet; `hack logs` still talks directly to Docker Compose or Loki.

+## Daemon (hackd)
src/lib/projects-registry.ts (1)

138-164: LGTM! Consider API consistency.

The implementation correctly mirrors the lookup logic of resolveRegisteredProjectByName. However, note the return type difference:

  • resolveRegisteredProjectByName returns ProjectContext | null
  • resolveRegisteredProjectById returns { project: ProjectContext; registration: RegisteredProject } | null

If this inconsistency is intentional (e.g., callers looking up by ID typically need the registration data), then this is fine. Otherwise, consider aligning the return types for API consistency.

examples/next-app/app/api/gateway/route.ts (2)

33-61: LGTM! Consider modern property check.

The validation logic is thorough with appropriate error codes. Line 58 uses the verbose Object.prototype.hasOwnProperty.call() pattern.

🔎 Optional modernization

Consider using the more concise Object.hasOwn() (ES2022+):

-...(Object.prototype.hasOwnProperty.call(opts.value, "body") ? { body: opts.value.body } : {})
+...(Object.hasOwn(opts.value, "body") ? { body: opts.value.body } : {})

Note: Ensure your target environment supports ES2022, or stick with the current approach for compatibility.


111-114: Consider aligning with the codebase's getString implementation.

This getString implementation differs from src/lib/guards.ts:

  • Returns null instead of undefined for missing/non-string values
  • Adds .trim() which may be desired here but creates inconsistency

If these differences are intentional for this specific use case, this is fine. Otherwise, consider using the shared utility from src/lib/guards.ts to maintain consistency.

tests/cloudflare-commands.test.ts (3)

9-37: Consider expanding test coverage for parseTunnelPrintArgs.

The existing tests cover basic success and failure paths well. Consider adding tests for:

  • Equals syntax: --hostname=value
  • Missing values: --hostname without a value
  • Other supported flags: --tunnel, --origin, --credentials-file
  • Multiple flag combinations
Example additional tests
test("parseTunnelPrintArgs parses equals syntax", () => {
  const result = parseTunnelPrintArgs({
    args: ["--hostname=gateway.example.com", "--out=./config.yml"]
  })
  expect(result.ok).toBe(true)
  if (!result.ok) return
  expect(result.value.hostname).toBe("gateway.example.com")
  expect(result.value.out).toBe("./config.yml")
})

test("parseTunnelPrintArgs rejects missing value", () => {
  const result = parseTunnelPrintArgs({ args: ["--hostname"] })
  expect(result.ok).toBe(false)
})

39-52: Consider expanding test coverage for parseTunnelStartArgs.

The tests cover the basic path. Consider adding:

  • Equals syntax: --config=value
  • Missing value handling
  • The --out flag (which maps to config based on the parser implementation)
Example additional tests
test("parseTunnelStartArgs parses out flag as config", () => {
  const result = parseTunnelStartArgs({
    args: ["--out", "./my-config.yml"]
  })
  expect(result.ok).toBe(true)
  if (!result.ok) return
  expect(result.value.config).toBe("./my-config.yml")
})

test("parseTunnelStartArgs rejects missing value", () => {
  const result = parseTunnelStartArgs({ args: ["--config"] })
  expect(result.ok).toBe(false)
})

54-67: Consider expanding test coverage for parseAccessSetupArgs.

The tests cover basic functionality. Consider adding:

  • Equals syntax: --ssh-hostname=value
  • Missing value handling
  • Individual flag usage (only --ssh-hostname or only --user)
Example additional tests
test("parseAccessSetupArgs parses equals syntax", () => {
  const result = parseAccessSetupArgs({
    args: ["--ssh-hostname=ssh.example.com", "--user=dimitri"]
  })
  expect(result.ok).toBe(true)
  if (!result.ok) return
  expect(result.value.sshHostname).toBe("ssh.example.com")
  expect(result.value.user).toBe("dimitri")
})

test("parseAccessSetupArgs rejects missing value", () => {
  const result = parseAccessSetupArgs({ args: ["--ssh-hostname"] })
  expect(result.ok).toBe(false)
})
src/control-plane/extensions/cloudflare/README.md (2)

162-166: Add language specifier to code block.

The fenced code block is missing a language specifier, which improves syntax highlighting and documentation rendering.

🔎 Suggested fix
-```
+```text
 Host ssh.example.com
   User <user>
   ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
</details>

---

`187-190`: **Add language specifier to code block.**

The fenced code block is missing a language specifier for better documentation rendering.



<details>
<summary>🔎 Suggested fix</summary>

```diff
-```
+```http
 CF-Access-Client-Id: <client-id>
 CF-Access-Client-Secret: <client-secret>
</details>

</blockquote></details>
<details>
<summary>docs/guides/remote-cloudflare.md (1)</summary><blockquote>

`56-60`: **Add language specifier to code fence.**

The SSH config code block is missing a language identifier for proper syntax highlighting.



<details>
<summary>🔎 Suggested fix</summary>

```diff
-```
+```ssh-config
 Host ssh.example.com
   User <user>
   ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h

</details>

</blockquote></details>
<details>
<summary>src/control-plane/extensions/builtins.ts (1)</summary><blockquote>

`7-13`: **Consider adding an explicit type annotation.**

While TypeScript inference works with `as const`, an explicit type annotation improves maintainability and IDE support:



<details>
<summary>🔎 Proposed enhancement</summary>

```diff
+import type { ExtensionDefinition } from "./types.ts"
+
 export const BUILTIN_EXTENSIONS = [
   TICKETS_EXTENSION,
   SUPERVISOR_EXTENSION,
   GATEWAY_EXTENSION,
   CLOUDFLARE_EXTENSION,
   TAILSCALE_EXTENSION
-] as const
+] as const satisfies readonly ExtensionDefinition[]
src/constants.ts (1)

38-41: Consider adding JSDoc comments for clarity.

The purpose of GLOBAL_ONLY_EXTENSION_IDS isn't immediately obvious. Adding documentation would help future maintainers understand why these specific extensions are restricted to global scope and how this list is used.

🔎 Suggested documentation
+/**
+ * Extension IDs that can only be enabled in global scope.
+ * These extensions require machine-wide access and cannot be project-scoped.
+ */
 export const GLOBAL_ONLY_EXTENSION_IDS = [
   "dance.hack.cloudflare",
   "dance.hack.tailscale"
 ] as const
docs/extensions.md (2)

93-93: Add language identifier to fenced code block.

For better syntax highlighting and documentation consistency, specify the language identifier for this code block.

🔎 Proposed fix
-```
+```bash
 hack x <namespace> <command> [args...]
</details>

---

`377-383`: **Add language identifier to JSON examples.**

For better syntax highlighting and documentation consistency, specify `json` as the language identifier for these code blocks.



<details>
<summary>🔎 Proposed fix</summary>

```diff
-```
+```json
 {"type":"start","jobId":"...","logsOffset":0,"eventsSeq":0}
 {"type":"log","stream":"combined","offset":128,"data":"..."}
 ...

Apply the same fix to the code blocks at lines 391 and 401.
</details>

</blockquote></details>
<details>
<summary>docs/gateway-api.md (2)</summary><blockquote>

`103-105`: **Add language identifier to fenced code block.**

For better syntax highlighting, specify the language identifier for this code block.



<details>
<summary>🔎 Proposed fix</summary>

```diff
-```
+```text
 https://gateway.dimitri.computer

Apply the same fix to the code block at line 147.
</details>

---

`319-322`: **Add blank lines around table for Markdown formatting.**

Markdown tables should be surrounded by blank lines for proper rendering across different parsers.



<details>
<summary>🔎 Proposed fix</summary>

```diff

Response:
| Field | Type | Description |


Apply the same fix to the table at line 385.
</details>

</blockquote></details>
<details>
<summary>src/control-plane/extensions/gateway/audit.ts (1)</summary><blockquote>

`34-35`: **Silent error swallowing hides audit failures.**

The empty `catch` block silently discards all errors. While audit logging shouldn't crash the gateway, completely hiding failures makes it impossible to diagnose issues (e.g., disk full, permission errors). Consider logging the error at debug/warn level or emitting a metric.


<details>
<summary>Proposed fix</summary>

```diff
   } catch {
+    // Audit writes are best-effort; log for observability but don't propagate.
+    // Consider: ctx.logger?.warn({ message: "Audit write failed", error })
   }
src/control-plane/extensions/tailscale/commands.ts (1)

62-68: Platform-specific install suggestion.

The error message suggests brew install tailscale, which is macOS-specific. On Linux, users typically install via their package manager or the official script. Consider a more generic message or detecting the platform.

Proposed fix
 async function ensureTailscale(): Promise<{ readonly ok: true } | { readonly ok: false; readonly error: string }> {
   const exitCode = await runTailscale({ args: ["--version"], inherit: false })
   if (exitCode !== 0) {
-    return { ok: false, error: "tailscale not found. Install with: brew install tailscale" }
+    return { ok: false, error: "tailscale not found. See https://tailscale.com/download for install instructions." }
   }
   return { ok: true }
 }
src/commands/doctor.ts (1)

435-457: Minor: duplicate resolveGatewayConfig() call.

resolveGatewayConfig() is called here and also in checkGatewayConfig(). Since doctor checks run sequentially, this results in redundant config resolution. Consider passing the resolved config or caching it, though this is a minor optimization given doctor runs infrequently.

src/lib/caddy-hosts.ts (1)

112-118: IPv4 validation allows leading zeros.

The regex ^\d{1,3}(\.\d{1,3}){3}$ allows values like 192.168.001.001 which, while technically parseable, can cause issues in some contexts where leading zeros are interpreted as octal. Consider whether stricter validation is needed.

src/control-plane/extensions/gateway/commands.ts (1)

45-75: token-list handler ignores args parameter.

The args parameter is destructured but never used. If this is intentional (no filtering/options supported), consider using _args or omitting it to signal intent.

🔎 Proposed fix
-    handler: async ({ args }) => {
+    handler: async () => {
examples/basic/gateway-demo.ts (1)

176-181: Consider handling Blob type explicitly.

The message data handling covers string and ArrayBuffer, but the fallback .toString() may not work correctly for Blob in all environments. In Bun, WebSocket messages are typically strings or ArrayBuffers, so this is likely fine for this demo.

src/control-plane/extensions/supervisor/runner.ts (1)

76-87: Potential race condition when writing to combined log file.

Both stdoutTask and stderrTask write to paths.combinedPath concurrently via appendFile. While appendFile is atomic at the OS level for small writes, interleaved chunks from stdout and stderr may result in mixed/garbled lines in the combined log.

Consider serializing writes to the combined file using a queue or mutex, or accept that combined output may have interleaved chunks (which may be acceptable for debugging purposes).

src/daemon/server.ts (1)

1070-1090: Consider caching the job store instance for stream sessions.

createJobStore is called on every log/event flush (every 500ms). While ensureDir is likely a no-op for existing directories, repeatedly instantiating the store adds minor overhead. Consider creating the store once in startJobStream and reusing it in flush functions.

src/commands/x.ts (1)

58-61: Returning exit code 1 for help display may be unexpected.

When no namespace is provided, renderDispatcherHelp is called and the function returns 1. Typically, help displays return 0 to indicate successful completion. Consider returning 0 here for consistency with other help flows.

🔎 Proposed fix
   if (!invocation.namespace) {
     await renderDispatcherHelp({ extensions: loaded.manager.listExtensions() })
-    return 1
+    return 0
   }
src/control-plane/extensions/gateway/tokens.ts (2)

150-167: Consider timing-safe comparison for token hash verification.

The hash comparison on line 156 uses JavaScript's === operator, which is not constant-time. While exploiting this timing side-channel is difficult (attacker would need to guess valid SHA256 hashes), using a timing-safe comparison function like crypto.timingSafeEqual would be more defense-in-depth.

🔎 Proposed fix
+import { timingSafeEqual } from "node:crypto"

+function safeHashCompare(a: string, b: string): boolean {
+  const bufA = Buffer.from(a, 'hex')
+  const bufB = Buffer.from(b, 'hex')
+  if (bufA.length !== bufB.length) return false
+  return timingSafeEqual(bufA, bufB)
+}

 export async function verifyGatewayToken(opts: {
   readonly rootDir: string
   readonly token: string
 }): Promise<GatewayTokenRecord | null> {
   const store = await readGatewayTokenStore({ rootDir: opts.rootDir })
   const hash = hashToken({ token: opts.token })
-  const match = store.tokens.find(token => token.hash === hash && !token.revokedAt) ?? null
+  const match = store.tokens.find(token => safeHashCompare(token.hash, hash) && !token.revokedAt) ?? null
   if (!match) return null

128-130: Redundant null check on line 129.

Line 128 retrieves store.tokens[idx] where idx was found via findIndex. If idx !== -1 (checked on line 126), existing cannot be null. The check on line 129 is defensive but unnecessary.

src/commands/usage.ts (2)

68-75: Surface control-plane parse errors instead of silently falling back

readControlPlaneConfig({}) is used here purely for config.usage, and any parseError from malformed control-plane config is ignored. That means a broken global config quietly degrades to defaults, which can be confusing when tweaking watch intervals/history.

Consider logging or displaying parseError (e.g. via logger or a display.panel in non‑JSON mode) so operators get immediate feedback when the config is invalid.


915-922: Avoid reimplementing isRecord/getString locally

This file defines its own isRecord/getString while src/lib/guards.ts already exports equivalent helpers (with a slightly stricter “not an array” check). Reusing the shared versions would keep semantics consistent across the codebase and avoid drift if those helpers ever evolve.

src/control-plane/sdk/config.ts (1)

204-225: Gateway enabled flag is derived only from project config, ignoring global enabled

In mergeControlPlaneLayers the merged gateway block is constructed as:

  • globalGateway from the global layer (bind/port/allowWrites/etc),
  • projectGateway from the project layer,
  • gatewayEnabled derived solely from projectGateway.enabled (or false when unset),
  • then merged.gateway = { ...globalGateway, enabled: gatewayEnabled }.

This means:

  • Any controlPlane.gateway.enabled value set in the global config is discarded.
  • config.gateway.enabled is always false when readControlPlaneConfig is called without a projectDir, and for projects that don’t define gateway.enabled themselves.

Given ExtensionManager.resolveNamespace special-cases dance.hack.gateway by looking at config.gateway.enabled, this likely doesn’t behave as intended: toggling controlPlane.gateway.enabled globally will never flip that flag.

You may want to either:

  • Honor the global enabled when the project layer omits it (e.g. “project overrides, otherwise global”), or
  • Treat gateway enablement purely as an extension flag (extensions["dance.hack.gateway"].enabled) and remove the config.gateway.enabled coupling.

Right now the combination is asymmetric and could surprise users configuring the gateway from the global config only.

src/commands/remote.ts (2)

421-479: Avoid duplicate control-plane config reads and consider exposing parse errors in status

collectRemoteStatusSnapshot already reads the per‑project control-plane config to determine projectGatewayEnabled. Later, it calls resolveCloudflareStatus with (await readControlPlaneConfig({})).config, which re‑parses the global config on every snapshot and ignores any parseError.

In the remote status and especially remote monitor flows, this means:

  • Extra JSON reads + zod parsing on each refresh.
  • Malformed control-plane config is silently treated as “no Cloudflare config”, with no warning to the user.

You could instead:

  • Read global + project control-plane config once at the call site, pass the global config (and optional parseError) down into both collectRemoteStatusSnapshot and resolveCloudflareStatus, and
  • Log or surface parseError via a warning panel in the status output so configuration issues are visible.

This keeps the status output accurate while avoiding redundant IO/parsing in tight loops.


788-1006: Protect monitor timers from unhandled async errors

In runRemoteMonitor, both timers use async callbacks without error handling:

  • statusTimer = setInterval(() => void renderStatus(), 2_000)
  • auditTimer = setInterval(() => void updateAuditLines(), 800)

If collectRemoteStatusSnapshot, readAuditLines, or any nested IO throws, those rejections will be unhandled (the void doesn’t catch them) and can take down the process.

A lightweight pattern is to wrap the calls in a small try/catch:

statusTimer = setInterval(() => {
  void (async () => {
    try {
      await renderStatus()
    } catch (err) {
      logger.warn({ message: `remote monitor status update failed: ${String(err)}` })
    }
  })()
}, 2_000)

and similarly for updateAuditLines. That keeps the TUI responsive even when a single poll fails.

src/control-plane/extensions/supervisor/job-store.ts (1)

91-166: Be explicit about duplicate job IDs and concurrent meta/event writes

createJob always creates/overwrites the job directory and meta.json for the supplied jobId, and both updateJobStatus and appendEventInternal follow a read‑modify‑write pattern on meta.json while appendEventInternal also appends to events.jsonl.

If:

  • createJob is accidentally called twice with the same jobId, or
  • Multiple async flows update status / append events for the same job concurrently,

you can end up with:

  • Previous meta/events silently overwritten on job creation retry.
  • Lost lastEventSeq updates or duplicated sequence numbers when two writers race on meta.json.

If the supervisor is strictly single‑writer per job (one process / one code path), this is fine, but it would be safer to:

  • Fail createJob if meta.json already exists for that id, and/or
  • In appendEvent/updateJobStatus, re‑read meta.json just before writing and serialize operations per job (e.g. via an in‑process per‑job mutex) if concurrent writes are possible.

That would make the store more robust to future usage patterns without changing the public API.

Also applies to: 213-237

src/control-plane/sdk/gateway-client.ts (2)

304-321: Consider sanitizing path parameters to prevent URL manipulation.

User-provided projectId, jobId, and shellId values are directly interpolated into URL paths. If these contain URL-special characters (e.g., ../, %2F, ?), it could lead to unintended path traversal or query injection.

🔎 Proposed fix: Add URL-safe encoding for path segments

Add a helper function and use it for all dynamic path segments:

function encodePathSegment(value: string): string {
  return encodeURIComponent(value)
}

Then update the path constructions:

-      path: `/control-plane/projects/${opts.projectId}/jobs`,
+      path: `/control-plane/projects/${encodePathSegment(opts.projectId)}/jobs`,

Apply similar changes to all path interpolations in listJobs, getJob, createJob, cancelJob, createShell, getShell, openJobStream, and openShellStream.

Also applies to: 342-350, 373-381, 383-402


479-489: Shallow validation in parse functions may allow malformed data through.

parseStatus validates status and version but the type also requires pid, started_at, and uptime_ms. Similarly, parseMetrics only checks status. The as casts assume all fields exist.

If the server returns partial data, runtime errors could occur when consumers access missing properties.

🔎 Proposed fix: Validate all required fields
 function parseStatus(value: unknown): GatewayStatus | null {
   if (!isRecord(value)) return null
-  if (typeof value["status"] !== "string" || typeof value["version"] !== "string") return null
+  if (
+    typeof value["status"] !== "string" ||
+    typeof value["version"] !== "string" ||
+    typeof value["pid"] !== "number" ||
+    typeof value["started_at"] !== "string" ||
+    typeof value["uptime_ms"] !== "number"
+  ) return null
   return value as GatewayStatus
 }

 function parseMetrics(value: unknown): GatewayMetrics | null {
   if (!isRecord(value)) return null
-  if (typeof value["status"] !== "string") return null
+  if (
+    typeof value["status"] !== "string" ||
+    typeof value["started_at"] !== "string" ||
+    typeof value["uptime_ms"] !== "number" ||
+    typeof value["refresh_count"] !== "number" ||
+    typeof value["refresh_failures"] !== "number" ||
+    typeof value["events_seen"] !== "number" ||
+    typeof value["streams_active"] !== "number"
+  ) return null
   return value as GatewayMetrics
 }

Comment thread docs/cli.md
Comment thread docs/guides/remote-tailscale.md
Comment thread docs/sdk.md
Create a typed HTTP/WS client for gateway orchestration:

```ts
import { createGatewayClient } from "./gateway-client.ts"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, read the docs/sdk.md file to see the imports in context
head -20 docs/sdk.md
echo "---"
tail -n +85 docs/sdk.md | head -20

Repository: hack-dance/hack-cli

Length of output: 1160


🏁 Script executed:

# Check SDK structure and package.json exports
cat package.json | jq '.exports, .main, .module' 2>/dev/null || cat package.json

echo "---"

# Look for SDK entry points
fd -t f \( -name 'index.ts' -o -name 'mod.ts' -o -name 'gateway-client.ts' -o -name 'config.ts' \) src/

Repository: hack-dance/hack-cli

Length of output: 335


🏁 Script executed:

# Read package.json to see actual exports configuration
cat package.json

Repository: hack-dance/hack-cli

Length of output: 1888


🏁 Script executed:

# Check the src/ directory structure to see what files exist
find src -type f -name '*.ts' -o -name '*.tsx' | head -30

Repository: hack-dance/hack-cli

Length of output: 735


🏁 Script executed:

# Search for gateway-client.ts and config.ts files anywhere in the repo
fd -t f 'gateway-client.ts|config.ts'

Repository: hack-dance/hack-cli

Length of output: 237


🏁 Script executed:

# Check if there's a docs/ directory structure
ls -la docs/ 2>/dev/null | head -20

Repository: hack-dance/hack-cli

Length of output: 738


🏁 Script executed:

# Look for SDK directory or similar
find . -type d -name '*sdk*' -o -type d -name '*gateway*' 2>/dev/null

Repository: hack-dance/hack-cli

Length of output: 193


🏁 Script executed:

# Check if sdk.md is in src/control-plane/sdk/ directory
find . -name 'sdk.md' -type f

Repository: hack-dance/hack-cli

Length of output: 76


🏁 Script executed:

# Check the structure of src/control-plane/sdk/
ls -la src/control-plane/sdk/

Repository: hack-dance/hack-cli

Length of output: 364


🏁 Script executed:

# Check if there's an index.ts in src/control-plane/sdk/
cat src/control-plane/sdk/index.ts 2>/dev/null || echo "No index.ts found"

Repository: hack-dance/hack-cli

Length of output: 80


🏁 Script executed:

# Check the README in src/control-plane/sdk/
cat src/control-plane/sdk/README.md

Repository: hack-dance/hack-cli

Length of output: 121


🏁 Script executed:

# Verify the exact imports shown in docs/sdk.md and check if they would work
cat -n docs/sdk.md | grep -A2 -B2 'import.*gateway-client\|import.*config'

Repository: hack-dance/hack-cli

Length of output: 384


🏁 Script executed:

# Check if there's any package export configuration or SDK build output
cat src/control-plane/sdk/README.md

Repository: hack-dance/hack-cli

Length of output: 121


🏁 Script executed:

# Look at the actual exports from gateway-client.ts to understand the API
head -50 src/control-plane/sdk/gateway-client.ts

Repository: hack-dance/hack-cli

Length of output: 1534


Update documentation import paths to match actual file structure.

The code examples in docs/sdk.md use relative import paths ("./gateway-client.ts" and "./config.ts" at lines 10 and 92) that don't work for users reading the documentation. The actual SDK files are located at src/control-plane/sdk/, so users would need either:

  • Correct relative paths from the repository root: "./src/control-plane/sdk/gateway-client.ts" and "./src/control-plane/sdk/config.ts"
  • Or clear documentation explaining how to import these modules after copying them to their project

Update the examples to use accurate import paths so users can follow the documentation without errors.

🤖 Prompt for AI Agents
In @docs/sdk.md at line 10, Documentation examples use incorrect relative
imports for createGatewayClient and config; update the import statements in
docs/sdk.md to reference the actual SDK module locations (e.g., import
createGatewayClient from "src/control-plane/sdk/gateway-client.ts" and import
config from "src/control-plane/sdk/config.ts"), or alternatively add a short
note explaining that users should copy the SDK files into their project and then
import from the local paths (e.g., "./gateway-client.ts" after copying); change
all examples referencing "./gateway-client.ts" and "./config.ts" to one of these
accurate options so the docs run as-is for readers.

Comment thread docs/supervisor.md
Comment on lines +36 to +38
```
ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifier to the code block.

The WebSocket URL code block is missing a language identifier. Consider adding text or bash for consistency with the rest of the documentation.

🔎 Proposed fix
-```
-ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream
-```
+```text
+ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream
+```

Based on static analysis hints from markdownlint-cli2.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream
```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

36-36: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In @docs/supervisor.md around lines 36 - 38, Update the Markdown code fence for
the WebSocket URL so it includes a language identifier (e.g., change the opening
``` to ```text) around the block containing
"ws://127.0.0.1:7788/control-plane/projects/$PROJECT_ID/jobs/$JOB_ID/stream" to
match the project's code block conventions and silence markdownlint warnings.

Comment on lines +81 to +99
async function printStatus(opts: {
readonly baseUrl: string
readonly headers: Record<string, string>
}): Promise<void> {
const res = await fetch(new URL("/v1/status", opts.baseUrl), { headers: opts.headers })
const body = await res.text()
process.stdout.write(`Status: ${body}\n`)
}

async function printProjects(opts: {
readonly baseUrl: string
readonly headers: Record<string, string>
}): Promise<void> {
const url = new URL("/v1/projects", opts.baseUrl)
url.searchParams.set("include_unregistered", "true")
const res = await fetch(url, { headers: opts.headers })
const body = await res.text()
process.stdout.write(`Projects: ${body}\n`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

HTTP requests lack error handling for network failures.

The printStatus and printProjects functions don't handle fetch exceptions. If the gateway is unreachable, the script will crash with an unhandled error.

🔎 Proposed fix to add try-catch
 async function printStatus(opts: {
   readonly baseUrl: string
   readonly headers: Record<string, string>
 }): Promise<void> {
-  const res = await fetch(new URL("/v1/status", opts.baseUrl), { headers: opts.headers })
-  const body = await res.text()
-  process.stdout.write(`Status: ${body}\n`)
+  try {
+    const res = await fetch(new URL("/v1/status", opts.baseUrl), { headers: opts.headers })
+    const body = await res.text()
+    process.stdout.write(`Status: ${body}\n`)
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Unknown error"
+    process.stderr.write(`Failed to fetch status: ${message}\n`)
+  }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @examples/basic/gateway-demo.ts around lines 81 - 99, printStatus and
printProjects currently call fetch without handling network errors; wrap the
fetch/response parsing in a try-catch inside each function (printStatus and
printProjects), catch any exceptions from fetch or res.text(), and handle them
by printing a clear error message (e.g., to process.stderr or process.stdout)
and returning early so the script doesn’t crash; ensure you still use
opts.headers and opts.baseUrl and preserve existing behavior on success.

Comment thread src/commands/config.ts
Comment on lines 331 to +406
function parseKeyPath(opts: { readonly raw: string }): readonly string[] {
return opts.raw
.split(".")
.map(part => part.trim())
.filter(part => part.length > 0)
const parts: string[] = []
let buffer = ""
let escape = false
let inBracket = false
let quote: "\"" | "'" | null = null

const pushBuffer = () => {
const trimmed = buffer.trim()
if (trimmed.length > 0) parts.push(trimmed)
buffer = ""
}

for (let i = 0; i < opts.raw.length; i += 1) {
const ch = opts.raw[i] ?? ""
if (inBracket) {
if (escape) {
buffer += ch
escape = false
continue
}
if (ch === "\\") {
escape = true
continue
}
if (quote) {
if (ch === quote) {
quote = null
continue
}
buffer += ch
continue
}
if (ch === "'" || ch === "\"") {
quote = ch
continue
}
if (ch === "]") {
inBracket = false
pushBuffer()
continue
}
buffer += ch
continue
}

if (escape) {
buffer += ch
escape = false
continue
}
if (ch === "\\") {
escape = true
continue
}
if (ch === ".") {
pushBuffer()
continue
}
if (ch === "[") {
if (buffer.trim().length > 0) {
pushBuffer()
} else {
buffer = ""
}
inBracket = true
continue
}
buffer += ch
}

if (escape) buffer += "\\"
if (buffer.length > 0) pushBuffer()

return parts
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Rename escape variable to avoid shadowing the global.

The static analysis tool correctly flags that escape shadows the global escape property. While the global is deprecated, shadowing it can cause confusion and trigger linter warnings.

🔎 Proposed fix: Rename to `isEscaped`
 function parseKeyPath(opts: { readonly raw: string }): readonly string[] {
   const parts: string[] = []
   let buffer = ""
-  let escape = false
+  let isEscaped = false
   let inBracket = false
   let quote: "\"" | "'" | null = null

   // ... (update all usages)

   for (let i = 0; i < opts.raw.length; i += 1) {
     const ch = opts.raw[i] ?? ""
     if (inBracket) {
-      if (escape) {
+      if (isEscaped) {
         buffer += ch
-        escape = false
+        isEscaped = false
         continue
       }
       if (ch === "\\") {
-        escape = true
+        isEscaped = true
         continue
       }
       // ... rest of bracket handling
     }

-    if (escape) {
+    if (isEscaped) {
       buffer += ch
-      escape = false
+      isEscaped = false
       continue
     }
     if (ch === "\\") {
-      escape = true
+      isEscaped = true
       continue
     }
     // ... rest of handling
   }

-  if (escape) buffer += "\\"
+  if (isEscaped) buffer += "\\"
   if (buffer.length > 0) pushBuffer()

   return parts
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function parseKeyPath(opts: { readonly raw: string }): readonly string[] {
return opts.raw
.split(".")
.map(part => part.trim())
.filter(part => part.length > 0)
const parts: string[] = []
let buffer = ""
let escape = false
let inBracket = false
let quote: "\"" | "'" | null = null
const pushBuffer = () => {
const trimmed = buffer.trim()
if (trimmed.length > 0) parts.push(trimmed)
buffer = ""
}
for (let i = 0; i < opts.raw.length; i += 1) {
const ch = opts.raw[i] ?? ""
if (inBracket) {
if (escape) {
buffer += ch
escape = false
continue
}
if (ch === "\\") {
escape = true
continue
}
if (quote) {
if (ch === quote) {
quote = null
continue
}
buffer += ch
continue
}
if (ch === "'" || ch === "\"") {
quote = ch
continue
}
if (ch === "]") {
inBracket = false
pushBuffer()
continue
}
buffer += ch
continue
}
if (escape) {
buffer += ch
escape = false
continue
}
if (ch === "\\") {
escape = true
continue
}
if (ch === ".") {
pushBuffer()
continue
}
if (ch === "[") {
if (buffer.trim().length > 0) {
pushBuffer()
} else {
buffer = ""
}
inBracket = true
continue
}
buffer += ch
}
if (escape) buffer += "\\"
if (buffer.length > 0) pushBuffer()
return parts
}
function parseKeyPath(opts: { readonly raw: string }): readonly string[] {
const parts: string[] = []
let buffer = ""
let isEscaped = false
let inBracket = false
let quote: "\"" | "'" | null = null
const pushBuffer = () => {
const trimmed = buffer.trim()
if (trimmed.length > 0) parts.push(trimmed)
buffer = ""
}
for (let i = 0; i < opts.raw.length; i += 1) {
const ch = opts.raw[i] ?? ""
if (inBracket) {
if (isEscaped) {
buffer += ch
isEscaped = false
continue
}
if (ch === "\\") {
isEscaped = true
continue
}
if (quote) {
if (ch === quote) {
quote = null
continue
}
buffer += ch
continue
}
if (ch === "'" || ch === "\"") {
quote = ch
continue
}
if (ch === "]") {
inBracket = false
pushBuffer()
continue
}
buffer += ch
continue
}
if (isEscaped) {
buffer += ch
isEscaped = false
continue
}
if (ch === "\\") {
isEscaped = true
continue
}
if (ch === ".") {
pushBuffer()
continue
}
if (ch === "[") {
if (buffer.trim().length > 0) {
pushBuffer()
} else {
buffer = ""
}
inBracket = true
continue
}
buffer += ch
}
if (isEscaped) buffer += "\\"
if (buffer.length > 0) pushBuffer()
return parts
}
🧰 Tools
🪛 Biome (2.1.2)

[error] 334-334: Do not shadow the global "escape" property.

Consider renaming this variable. It's easy to confuse the origin of variables when they're named after a known global.

(lint/suspicious/noShadowRestrictedNames)

🤖 Prompt for AI Agents
In @src/commands/config.ts around lines 331 - 406, In parseKeyPath rename the
local boolean variable escape to a non-shadowing name (e.g. isEscaped) and
update every reference/assignment (checks, setting true/false, and the final if
(escape) branch) so behavior is unchanged; ensure the helper pushBuffer and all
conditional logic in the inBracket and non-bracket branches use the new
identifier so no functionality or control flow is altered.

Comment on lines +168 to +223
function resolveNamespace(opts: {
readonly extension: ExtensionDefinition
readonly config: ControlPlaneConfig
readonly used: ReadonlySet<string>
}): NamespaceResolution {
const preferred = opts.extension.manifest.cliNamespace
const extensionConfig = opts.config.extensions?.[opts.extension.manifest.id]
const override =
isRecord(extensionConfig) && typeof extensionConfig["cliNamespace"] === "string" ?
extensionConfig["cliNamespace"]
: undefined
const enabled =
(isRecord(extensionConfig) && extensionConfig["enabled"] === true ? true : false) ||
(opts.extension.manifest.id === "dance.hack.gateway" && opts.config.gateway.enabled === true)
const desired = sanitizeNamespace(override ?? preferred)

if (desired.length > 0 && !opts.used.has(desired) && !RESERVED_NAMESPACES.has(desired)) {
return {
resolved: { ...opts.extension, namespace: desired, enabled }
}
}

const fallback = buildFallbackNamespace({
base: desired.length > 0 ? desired : "ext",
extensionId: opts.extension.manifest.id,
used: opts.used
})

return {
resolved: { ...opts.extension, namespace: fallback, enabled },
warning:
desired.length === 0 ?
`Extension namespace missing for ${opts.extension.manifest.id}; using ${fallback}`
: `Namespace "${desired}" already used; using ${fallback} for ${opts.extension.manifest.id}`
}
}

function buildFallbackNamespace(opts: {
readonly base: string
readonly extensionId: string
readonly used: ReadonlySet<string>
}): string {
const hash = createHash("sha1").update(opts.extensionId).digest("hex").slice(0, 6)
const fallback = `${opts.base}.${hash}`
if (!opts.used.has(fallback) && !RESERVED_NAMESPACES.has(fallback)) {
return fallback
}
return `${opts.base}.${hash}.${Date.now().toString(36)}`
}

function sanitizeNamespace(raw: string): string {
const trimmed = raw.trim().toLowerCase()
const replaced = trimmed.replaceAll("_", "-").replaceAll(" ", "-")
const cleaned = replaced.replaceAll(/[^a-z0-9-]/g, "")
return cleaned.replaceAll(/-+/g, "-").replaceAll(/^-|-$/g, "")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden sanitizeNamespace against missing cliNamespace / overrides

resolveNamespace currently does:

const preferred = opts.extension.manifest.cliNamespace

const desired = sanitizeNamespace(override ?? preferred)

while sanitizeNamespace assumes a concrete string and immediately calls raw.trim(). If an extension definition has no manifest.cliNamespace and there’s no cliNamespace override in config, override ?? preferred will be undefined, and this call will throw at runtime.

To keep namespace resolution robust (especially for future extensions that may intentionally omit a CLI namespace), consider one of:

  • Supplying a safe default at the call site, e.g.
    const desired = sanitizeNamespace(override ?? preferred ?? ""), or
  • Widening sanitizeNamespace to accept string | undefined and coerce internally:
    const trimmed = (raw ?? "").trim().toLowerCase().

That way extensions without an explicit namespace still resolve cleanly via the fallback logic instead of crashing the manager.

🤖 Prompt for AI Agents
In @src/control-plane/extensions/manager.ts around lines 168 - 223,
sanitizeNamespace currently calls raw.trim() and will throw when
resolveNamespace passes undefined for override/preferred; make the namespace
sanitization robust by either changing sanitizeNamespace to accept string |
undefined and coerce with (raw ?? "").trim().toLowerCase() or update the call
site in resolveNamespace to pass a safe default (sanitizeNamespace(override ??
preferred ?? "")). Update only sanitizeNamespace or the call in resolveNamespace
(referencing sanitizeNamespace and resolveNamespace) so undefined cliNamespace
values no longer cause runtime errors and fallback logic can run.

Comment on lines +109 to +121
}).catch(async error => {
logger.error({ message: `Job failed: ${formatError(error)}` })
await store.updateJobStatus({ jobId, status: "failed" })
await store.appendEvent({
jobId,
type: "job.failed",
payload: { error: formatError(error) }
})
const fallback: JobRunResult = { jobId, status: "failed", exitCode: 1 }
return fallback
}).finally(() => {
runningJobs.delete(jobId)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential duplicate failure events if runJob throws after recording failure.

If runJob itself records a job.failed event (e.g., at line 60-64 in runner.ts for spawn errors) and then throws, this catch handler will record another job.failed event. Consider checking the job status before appending the failure event, or ensure runJob doesn't throw after recording failures.

🤖 Prompt for AI Agents
In @src/control-plane/extensions/supervisor/service.ts around lines 109 - 121,
The catch block for runJob can append a duplicate "job.failed" event if runJob
already recorded failure then threw; modify the catch handler (the Promise.catch
that logs error, calls store.updateJobStatus and store.appendEvent) to first
read the current job status (e.g., via store.getJob or store.getJobStatus) and
only call store.appendEvent({ type: "job.failed", ... }) if the status is not
already "failed" (leave updateJobStatus as-is or make it idempotent).
Alternatively, have runJob return a sentinel or set a flag when it has already
recorded failure and check that flag here before appending; ensure
runningJobs.delete(jobId) remains in the finally block.

Comment on lines +260 to +266
if (opts.env) {
for (const [key, value] of Object.entries(opts.env)) {
env[key] = value
}
}
env["TERM"] = process.env.TERM ?? DEFAULT_TERM
return env

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

User-provided TERM in env overrides is silently overwritten.

Line 265 always sets TERM after merging user-provided environment variables, which means any custom TERM value passed in opts.env will be ignored.

🔎 Proposed fix to preserve user override
   if (opts.env) {
     for (const [key, value] of Object.entries(opts.env)) {
       env[key] = value
     }
   }
-  env["TERM"] = process.env.TERM ?? DEFAULT_TERM
+  if (!env["TERM"]) {
+    env["TERM"] = process.env.TERM ?? DEFAULT_TERM
+  }
   return env
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (opts.env) {
for (const [key, value] of Object.entries(opts.env)) {
env[key] = value
}
}
env["TERM"] = process.env.TERM ?? DEFAULT_TERM
return env
if (opts.env) {
for (const [key, value] of Object.entries(opts.env)) {
env[key] = value
}
}
if (!env["TERM"]) {
env["TERM"] = process.env.TERM ?? DEFAULT_TERM
}
return env
🤖 Prompt for AI Agents
In @src/control-plane/extensions/supervisor/shell-service.ts around lines 260 -
266, The code currently overwrites any user-provided TERM because env["TERM"] is
always set after merging opts.env; update the logic in the shell-service merging
block (the env/opts.env handling) to only set TERM when not already provided by
the merged env — e.g., after merging opts.env into env, check if env.TERM is
undefined/null and then assign process.env.TERM ?? DEFAULT_TERM — referencing
the env variable, opts.env, and DEFAULT_TERM in your change.

Comment thread src/lib/caddy-hosts.ts
Comment on lines +82 to +99
function extractExtraHostsIp(extraHosts: unknown): string | null {
if (isRecord(extraHosts)) {
for (const value of Object.values(extraHosts)) {
if (typeof value === "string" && value.length > 0) return value
}
return null
}

if (Array.isArray(extraHosts)) {
for (const entry of extraHosts) {
if (typeof entry !== "string") continue
const ip = parseExtraHostEntry(entry)
if (ip) return ip
}
}

return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Record format returns raw value without IPv4 validation.

When extraHosts is a record (object format), the function returns the value directly without validating it's a proper IPv4 address. This is inconsistent with the array path which validates via parseExtraHostEntryisIpv4.

🔎 Proposed fix to validate IPv4 for record format
 function extractExtraHostsIp(extraHosts: unknown): string | null {
   if (isRecord(extraHosts)) {
     for (const value of Object.values(extraHosts)) {
-      if (typeof value === "string" && value.length > 0) return value
+      if (typeof value === "string" && isIpv4(value)) return value
     }
     return null
   }
🤖 Prompt for AI Agents
In @src/lib/caddy-hosts.ts around lines 82 - 99, extractExtraHostsIp currently
returns raw values from the record branch without IPv4 validation; update the
record handling in extractExtraHostsIp to validate each value (use existing
helpers parseExtraHostEntry or isIpv4) before returning, e.g., iterate
Object.values(extraHosts), coerce non-strings, run parseExtraHostEntry(entry) or
isIpv4(value) and return the first valid IPv4 string, otherwise continue and
finally return null; keep the Array.isArray branch behavior unchanged and reuse
parseExtraHostEntry/isIpv4 to ensure consistent validation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In @docs/gateway-api.md:
- Around line 149-151: The Markdown code block containing the WebSocket URL
"wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>"
is missing a language identifier; update the opening fence from ``` to ```bash
(and keep the closing fence as ```) so the block becomes a bash-specified code
block for proper syntax highlighting and tooling recognition.
- Around line 319-325: The markdown has a table starting with "| Field | Type |
Description |" placed immediately after a JSON code block under the "Response:"
section for the GET /control-plane/projects/:projectId/jobs endpoint; insert a
single blank line between the preceding code block (or the "Response:" line) and
that table so the table is separated by one empty line to satisfy markdown style
guidelines and render correctly.
- Around line 105-107: Update the Markdown code fence that currently wraps the
URL "https://gateway.dimitri.computer" to include a language identifier (e.g.,
change the opening triple backticks to "```bash" or "```text") so the block
renders with the specified language; target the code block containing that exact
URL and replace the opening fence accordingly while keeping the closing "```"
unchanged.
- Around line 385-391: Add a blank line between the preceding JSON/code block
and the table that begins with the "Response:" heading (the table row starting
with "| Field | Type | Description |") so the markdown has an empty line before
the table for proper rendering; update the docs/gateway-api.md section showing
the "Response:" table under the GET
/control-plane/projects/:projectId/shells/:shellId endpoint to insert one blank
line immediately before the table.

In @docs/guides/remote-cloudflare.md:
- Around line 56-62: The SSH config example code block in the "Optional
~/.ssh/config shortcut" section is missing a language identifier; update the
opening fence from ``` to ```bash (or ```text) so the snippet starting with
"Host ssh.example.com" and containing "ProxyCommand
/opt/homebrew/bin/cloudflared access ssh --hostname %h" uses proper syntax
highlighting.
🧹 Nitpick comments (9)
src/ui/clipboard.ts (1)

18-50: Well-implemented with good error handling. Consider adding a timeout.

The function properly handles the clipboard helper lifecycle and provides clear error messages. The logic flow is correct and defensive.

Consider adding a timeout to proc.exited to prevent indefinite waiting if a clipboard helper hangs:

🔎 Example timeout implementation
- const exitCode = await proc.exited
+ const timeout = new Promise<number>((_, reject) => 
+   setTimeout(() => reject(new Error('Clipboard operation timed out')), 5000)
+ )
+ 
+ let exitCode: number
+ try {
+   exitCode = await Promise.race([proc.exited, timeout])
+ } catch (err) {
+   proc.kill()
+   return { ok: false, error: 'Clipboard operation timed out after 5s' }
+ }
  if (exitCode !== 0) {
README.md (2)

147-156: Update command examples for clarity and formatting.

Lines 147–156 collapse the previous command examples into a brief summary. While concise, the transition from prose ("Run hack help...") directly into a command list could benefit from better visual separation. Consider converting to a fenced code block with bash identifier for consistency with the rest of the README:

🔎 Proposed formatting
-Run `hack help` (or `hack help <command>`) for full usage.
-
-Common:
-- `hack global install|up|down`
-- `hack init|up|down|logs|open|tui`
-- `hack status`
-- `hack remote setup`
-- `hack gateway enable`
-
-Full command table + flags: `docs/cli.md`.
+Run `hack help` (or `hack help <command>`) for full usage.
+
+```bash
+# Common commands
+hack global install|up|down
+hack init|up|down|logs|open|tui
+hack status
+hack remote setup
+hack gateway enable
+```
+
+Full command table + flags: `docs/cli.md`.

493-495: Clarify OAuth alias configuration for .hack.gy domain.

Line 493–495 mention that hack global install configures *.hack.gy via dnsmasq. This is a significant setup step; ensure the documentation or help text in the CLI makes this clear, and consider linking to troubleshooting (e.g., if dnsmasq fails or the user is on a non-macOS system).

docs/guides/init-project.md (4)

13-14: Consider briefly documenting what .hack/ contains.

Readers might benefit from knowing whether .hack/ includes Docker Compose files, config files, or both, to understand the generated structure and help with troubleshooting.


15-15: Clarify what hack open resolves to and how users should use the output.

It would be helpful to mention what kind of URL is returned (e.g., a proxy URL pointing to services) and how users interact with it, especially given the networking notes that follow.


20-20: Add concrete examples for log retention configuration.

Providing one or two YAML/JSON snippet examples (e.g., "7d", "30d") or a brief explanation of the format would make this setting more accessible and reduce trial-and-error.


22-25: Enhance networking guidance with context on why this matters.

The note correctly addresses localhost isolation, but adding a brief explanation—such as "because containers cannot reach the host's localhost"—would help users understand the why behind the translation. Also consider mentioning that this applies only to services running inside containers, not the user's local machine.

src/tui/hack-tui.ts (2)

1255-1282: Consider adding error handling for Caddy status resolution.

Both resolveGlobalCaddyIp and readInternalExtraHostsIp are awaited without try-catch. If either throws (e.g., due to file system errors or missing configuration), the error will propagate and potentially disrupt the refresh cycle.

🔎 Proposed fix
 const refreshCaddyStatus = async () => {
   if (!isActive) return
   const now = Date.now()
   if (now - lastCaddyCheckAt < caddyCheckIntervalMs) return
   lastCaddyCheckAt = now

+  try {
     const nextCaddyIp = await resolveGlobalCaddyIp()
     const nextMappedIp = await readInternalExtraHostsIp({ projectDir: project.projectDir })
     // ... rest of logic
+  } catch {
+    // Caddy status unavailable - silently ignore
+    return
+  }

1491-1494: Consider adding a timeout to the Docker stats command.

The docker stats --no-stream command typically completes quickly, but if Docker is unresponsive or there are many containers, this could block indefinitely. The exec call has no timeout configured.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between eafa98e and fb49a67.

📒 Files selected for processing (9)
  • README.md
  • docs/gateway-api.md
  • docs/gateway.md
  • docs/guides/init-project.md
  • docs/guides/remote-cloudflare.md
  • docs/guides/remote-ssh.md
  • docs/guides/remote-tailscale.md
  • src/tui/hack-tui.ts
  • src/ui/clipboard.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/gateway.md
  • docs/guides/remote-ssh.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/guides/remote-tailscale.md
🧰 Additional context used
🪛 Biome (2.1.2)
src/tui/hack-tui.ts

[error] 3192-3192: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 3192-3192: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)

🪛 LanguageTool
docs/gateway-api.md

[grammar] ~1-~1: Ensure spelling is correct
Context: # Gateway API (hackd HTTP/WS) The gateway exposes a small, ...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~111-~111: In American English, abbreviations like “etc.” require a period.
Context: ... SSH from iOS clients (Terminus, Blink, etc): 1) Join the tailnet on your laptop: ...

(ETC_PERIOD)


[grammar] ~123-~123: Use a hyphen to join words.
Context: ...you want a stable ssh.dimitri.computer style hostname: - Point the DNS record ...

(QB_NEW_EN_HYPHEN)


[grammar] ~296-~296: Use a hyphen to join words.
Context: ...items | PsItem[] | docker compose ps style rows | ### POST /control-plane/pr...

(QB_NEW_EN_HYPHEN)

🪛 markdownlint-cli2 (0.18.1)
docs/gateway-api.md

105-105: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


149-149: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


321-321: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


387-387: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

docs/guides/remote-cloudflare.md

58-58: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🔇 Additional comments (18)
src/ui/clipboard.ts (2)

1-16: LGTM! Clean type definitions and comprehensive platform support.

The type definitions are well-structured with proper readonly modifiers, and the CLIPBOARD_COMMANDS array provides good cross-platform coverage with correct arguments for each clipboard utility.


52-61: LGTM! Solid command resolution logic.

The platform filtering and availability checking using Bun.which is correct. The PATH validation is good defensive programming that handles edge cases appropriately.

README.md (2)

6-6: Style change from <p> to <pre> for ASCII art rendering—verify display.

Changed the project header from a <p> tag to a <pre> tag with inline styles. This may affect rendering on GitHub and other markdown renderers that have different CSS contexts. Verify that the ASCII art displays correctly in all intended platforms (GitHub README, docs sites, etc.).


197-248: Control plane + extensions section is comprehensive—verify all referenced docs exist and are linked.

The new section documents the gateway, remote access, supervisor, and token management well. Ensure that:

  • docs/extensions.md (referenced at line 207) exists and is complete
  • docs/gateway-api.md (referenced at line 247) is fully documented
  • docs/supervisor.md (referenced at line 288) is included in the PR
  • All examples (e.g., hack remote setup, hack x gateway token-create) match the actual CLI interface
docs/gateway-api.md (1)

537-571: Verify demo script and E2E test references exist and are executable.

The documentation references:

  • examples/basic/gateway-demo.ts (line 539)
  • examples/next-app/app/gateway/page.tsx (line 545)
  • Test command bun run test:e2e:gateway (line 570)

Ensure these files exist in the PR, are properly documented, and that the test environment variables and invocation method are correct.

docs/guides/init-project.md (1)

5-10: Command sequence is clear and practical.

The three-step initialization flow is easy to follow and well-ordered. Good progression from setup to daemon startup to access.

src/tui/hack-tui.ts (12)

92-101: LGTM on the scroll change callback pattern.

The PausableScrollBoxRenderable class cleanly extends ScrollBoxRenderable to add scroll event interception for pause/resume functionality.


3462-3496: LGTM on the stream consumption implementation.

Good use of try-finally to ensure the reader lock is released. The buffered line parsing and early termination via isActive() are correctly implemented.


1840-1896: LGTM on history loading with capacity management.

Good implementation that respects maxEntries limit, provides user feedback via toast when capped, and correctly handles both Loki and Compose backends with different pagination strategies.


1993-2136: LGTM on the dual-mode search implementation.

Clean separation between local in-memory search (for non-Loki backends) and remote Loki search. Both paths correctly update UI state and handle edge cases.


2816-2849: LGTM on the log continuation detection.

The heuristics for collapsing multi-line log entries (stack traces, JSON structures, indented content) are well-considered and will improve log readability.


2723-2746: LGTM on byte parsing with comprehensive unit support.

Good coverage of both SI (KB, MB, GB) and binary (KiB, MiB, GiB) units with case-insensitive matching.


2293-2304: LGTM on selection change handling.

Good UX decision to pause log following when text is selected, preventing the jarring experience of content moving while trying to copy.


1350-1393: LGTM on responsive sidebar layout.

Good progressive degradation strategy: first hide resources panel, then switch to compact meta, then shrink services. The early return for zero height prevents issues during initialization.


2517-2532: LGTM on project ID resolution with fallback.

Robust implementation with try-catch for registry access failures, priority given to directory match, and case-insensitive name fallback.


162-169: LGTM on renderer initialization with mouse support.

Good configuration choices: alternate screen for clean exit, mouse support for scroll interactions, and reasonable FPS target.


3188-3194: Control character regex is intentional - static analysis false positive.

The regex [\x00-\x1f\x7f] at line 3189 intentionally matches ASCII control characters (0x00-0x1F and DEL 0x7F) to normalize text that may contain terminal escape sequences or other control characters. This is correct behavior for log text sanitization.


2217-2221: The code is already safe. At line 2217, there is a conditional check if (handleSelectionChange) that ensures the listener is only removed when handleSelectionChange is not null. Since handleSelectionChange is initialized to null at line 109 and assigned its actual value at line 2293, the listener can only be removed if it was previously added, which occurs at line 2304 after the assignment.

Likely an incorrect or invalid review comment.

Comment thread docs/gateway-api.md
Comment on lines +105 to +107
```
https://gateway.dimitri.computer
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifier to URL display block.

The code block at line 105 displays a gateway URL without a language specifier. Update to \``bashor```text`:

🔎 Proposed fix
 Cloudflare creates a CNAME to `<tunnel-id>.cfargotunnel.com` under your zone. The gateway URL is:
 
-```
+```bash
 https://gateway.dimitri.computer
-```
+```

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

105-105: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In @docs/gateway-api.md around lines 105 - 107, Update the Markdown code fence
that currently wraps the URL "https://gateway.dimitri.computer" to include a
language identifier (e.g., change the opening triple backticks to "```bash" or
"```text") so the block renders with the specified language; target the code
block containing that exact URL and replace the opening fence accordingly while
keeping the closing "```" unchanged.

Comment thread docs/gateway-api.md
Comment on lines +149 to +151
```
wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifier to WebSocket URL block.

The code block at line 149 displays a WebSocket URL without a language specifier. Update to \``bash` or similar:

🔎 Proposed fix
 Browser note: the native WebSocket API cannot set headers. For browser clients, pass the token
 as a query param on the WS URL:
 
-```
+```bash
 wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>
-```
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>
```
Browser note: the native WebSocket API cannot set headers. For browser clients, pass the token
as a query param on the WS URL:
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

149-149: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In @docs/gateway-api.md around lines 149 - 151, The Markdown code block
containing the WebSocket URL
"wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>"
is missing a language identifier; update the opening fence from ``` to ```bash
(and keep the closing fence as ```) so the block becomes a bash-specified code
block for proper syntax highlighting and tooling recognition.

Comment thread docs/gateway-api.md
Comment on lines +319 to +325

Response:
| Field | Type | Description |
| --- | --- | --- |
| `job` | JobMeta | Job metadata |

### GET /control-plane/projects/:projectId/jobs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank line before table to meet markdown style guidelines.

Line 321 starts a table immediately after a JSON code block without a blank line. Add a blank line for proper spacing:

🔎 Proposed fix
 }

Response:
| Field | Type | Description |

</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

321-321: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @docs/gateway-api.md around lines 319 - 325, The markdown has a table
starting with "| Field | Type | Description |" placed immediately after a JSON
code block under the "Response:" section for the GET
/control-plane/projects/:projectId/jobs endpoint; insert a single blank line
between the preceding code block (or the "Response:" line) and that table so the
table is separated by one empty line to satisfy markdown style guidelines and
render correctly.


</details>

<!-- fingerprinting:phantom:triton:mongoose -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment thread docs/gateway-api.md
Comment on lines +385 to +391

Response:
| Field | Type | Description |
| --- | --- | --- |
| `shell` | ShellMeta | Shell metadata |

### GET /control-plane/projects/:projectId/shells/:shellId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank line before table to meet markdown style guidelines.

Line 387 starts a table immediately after a JSON code block without a blank line. Add a blank line for proper spacing:

🔎 Proposed fix
 }

Response:
| Field | Type | Description |

</details>




> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

387-387: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @docs/gateway-api.md around lines 385 - 391, Add a blank line between the
preceding JSON/code block and the table that begins with the "Response:" heading
(the table row starting with "| Field | Type | Description |") so the markdown
has an empty line before the table for proper rendering; update the
docs/gateway-api.md section showing the "Response:" table under the GET
/control-plane/projects/:projectId/shells/:shellId endpoint to insert one blank
line immediately before the table.


</details>

<!-- fingerprinting:phantom:triton:mongoose -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment on lines +56 to +62
Optional `~/.ssh/config` shortcut:

```
Host ssh.example.com
User <user>
ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifier to SSH config code block.

The SSH config example at lines 56–62 is missing a language identifier. Update to \``bashor```text` for proper syntax highlighting:

🔎 Proposed fix
-Optional `~/.ssh/config` shortcut:
-
-```
+Optional `~/.ssh/config` shortcut:
+
+```bash
 Host ssh.example.com
   User <user>
   ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

58-58: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In @docs/guides/remote-cloudflare.md around lines 56 - 62, The SSH config
example code block in the "Optional ~/.ssh/config shortcut" section is missing a
language identifier; update the opening fence from ``` to ```bash (or ```text)
so the snippet starting with "Host ssh.example.com" and containing "ProxyCommand
/opt/homebrew/bin/cloudflared access ssh --hostname %h" uses proper syntax
highlighting.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🤖 Fix all issues with AI agents
In @CLAUDE.md:
- Line 44: Fix the two typos in the sentence: change "sensure" to "ensure" and
"tpye" to "type", resulting in: "Never use any types and always default to
leveraging generics and smart types to ensure the best possible type inference
across the project."

In @docs/cli.md:
- Line 762: The Markdown tables for the "hack mcp install" and "print options"
sections have mismatched column counts; update both tables so the header row and
every data row use the same number of columns (4), ensuring the flag row
containing `--scope <user|project>` aligns with the header columns (e.g., ``Flag
| Type | Default | Description``) and that each cell in that row maps to those
four headers; fix any extra or missing pipe characters so both tables render
with consistent 4-column structures.
- Line 61: The table row for the `logs` subcommand has an inconsistent number of
cells causing malformed Markdown; update the table to have the same number of
columns as the header (likely Flag | Type | Default | Description) and split the
current row’s five cells into four by mapping the command name (`logs`) into the
Flag column, the target services (caddy|grafana|loki|alloy) into the Description
column (or Type if you intend a separate Type), provide a Default value if
applicable (or `-`), and ensure the header and all rows use the same
pipe-separated column count so the table renders correctly.
- Around line 852-853: The two Markdown table rows for the hack log-pipe options
are malformed (extra pipe separators) causing column-count mismatches; locate
the rows containing `--format <auto|docker-compose|plain>` and `--stream
<stdout|stderr>` and edit each so they have exactly four pipe-delimited cells
matching the table header (Option | Type | Default | Description), removing any
stray pipes or merged cells, keep backticks around option and default values,
and verify the table alignment renders correctly.
- Line 740: The table row for the `-c`, `--client <cursor|claude|codex|print>`
option is producing extra pipe separators and breaking the 4-column table
structure; fix it by ensuring the entire option cell is a single cell (escape or
wrap the inner pipes such as `<cursor|claude|codex|print>` so they don't split
columns) and align it with the existing header columns, keeping the row to
exactly four pipe-separated cells; update the offending row where the `-c`,
`--client <cursor|claude|codex|print>` entry appears to use proper escaping or
quoting so the table parser treats the value as one cell.
- Line 905: The table row for the "hack the planet" options is malformed
(extra/incorrect pipe delimiters) causing a 7-column row instead of 4; find the
row containing the text `--variant <cut|mash|cycle|random>` and correct its pipe
separators so it has exactly four cells (e.g., `| \`--variant
<cut|mash|cycle|random>\` | string | \`cycle\` | Animation variant |`), also
verify the table header/divider uses the same 4-column pipe structure and escape
or remove any stray pipes inside cells.

In @docs/extensions.md:
- Line 142: The Gateway extension token-create options table has a row for
`--scope <read|write>` with four pipe-separated columns but the table header is
malformed with a different column count; fix by making the header match four
columns (e.g., "Option | Type | Default | Description") and ensure the separator
row contains four header separators (e.g., "| --- | --- | --- | --- |"), then
verify the `--scope` row and other data rows align to those four columns so the
table renders correctly.

In @src/commands/internal.ts:
- Around line 10-17: The option definition for optPath uses defaultValue: null
which conflicts with the expected type string | undefined; update the
defineOption call for optPath to set defaultValue: undefined (or remove the
defaultValue property) so the default matches the option type; ensure the change
is made in the optPath constant where defineOption is invoked.
- Around line 76-79: In resolveStartDir, guard against non-string option values
before calling trim by reading args.options.path into a variable (e.g., pathOpt)
and performing a type-narrow: use typeof pathOpt === 'string' ? pathOpt.trim() :
'' for fromOpt, then keep the existing logic (fromOpt.length > 0 ? fromOpt :
ctx.cwd); this avoids calling trim on number/boolean values while preserving
behavior of resolveStartDir and references to args.options.path.
- Around line 23-51: The three command specs extraHostsSetSpec,
extraHostsUnsetSpec, and extraHostsListSpec currently use an invalid group value
"Internal"; change their group property to one of the allowed CliGroup strings
(for example "Global" or "Project") so the type matches the CliGroup union,
e.g., replace group: "Internal" with group: "Global" (or another valid option)
in each of those defineCommand calls.
- Around line 126-152: The handler signatures use string literal arrays for
positionals but must reference the PositionalSpec types; update the args generic
for both handleExtraHostsSet and handleExtraHostsUnset to use the actual
positionals type (e.g., typeof extraHostsSetSpec['positionals'] or a shared
const like extraHostsSetPositionals) instead of readonly ["hostname","target"],
ensuring the same positional type is used in the spec and the CommandArgs
generic.

In @src/commands/project.ts:
- Line 7: The package version for @clack/prompts referenced by the import (e.g.,
the symbol select imported from '@clack/prompts') is invalid (1.0.0-alpha.9) and
will fail to install; update package.json to point to a published version (for
example 1.0.0-alpha.6 or the stable 0.11.0) or convert it to the correct
git/private-registry reference, then reinstall so the import of select resolves
correctly.

In @src/commands/setup.ts:
- Around line 131-138: The summary on the ticketsSpec command is incorrect (it
references Codex); update the summary field in the ticketsSpec definition to
describe the tickets feature (e.g., "Install Tickets skill for hack ticket
management" or similar) so it accurately reflects the tickets functionality
instead of copying codexSpec text.

In @src/control-plane/extensions/tickets/store.ts:
- Around line 194-202: computeNextTicketId currently scans materializeTickets,
uses parseTicketNumber and formatTicketId, then code later appends a ticket
event — this allows races and duplicate IDs when concurrent processes create
tickets; fix by making ID generation atomic: either (A) add a dedicated counter
store and replace computeNextTicketId to perform an atomic increment (e.g.,
increment-and-read a counter file/DB entry) so each process gets a unique
sequential ID, or (B) acquire a process-wide lock around computeNextTicketId +
the append step (use a lock file or mutex) to serialize creation, or (C) append
a collision-safe suffix (e.g., a short UUID or timestamp) to the value returned
by formatTicketId to guarantee uniqueness; update all callers of
computeNextTicketId and the ticket-id format logic so parsing/formatting remains
consistent with the chosen approach.

In @src/control-plane/extensions/tickets/util.ts:
- Around line 17-24: The parseTicketNumber function currently allows negative
results (e.g., "T--5" -> -5); update parseTicketNumber to reject non-positive
values by checking the parsed numeric value after Number.isFinite — if n <= 0
return null — and only then return Math.trunc(n); keep the checks for the "T-"
prefix and numeric parsing as-is.
🧹 Nitpick comments (13)
src/commands/internal.ts (2)

99-104: Consider logging a warning when JSON parsing fails.

Silently returning an empty object when JSON parsing fails could mask configuration issues. A warning log would help users debug malformed extra-hosts.json files.

💡 Suggested improvement
   try {
     parsed = JSON.parse(text)
   } catch {
+    logger.warn({ message: `Failed to parse ${path}, treating as empty` })
     return {}
   }

154-181: Redundant ensureDir call after reading the file.

The directory and file must exist if readInternalExtraHosts successfully read content and found the hostname. The ensureDir call on line 175 is unnecessary and adds a small overhead.

♻️ Proposed simplification
   delete existing[hostname]
-  await ensureDir(resolve(project.projectDir, ".internal"))
   await writeInternalExtraHosts(path, existing)
.gitignore (1)

45-45: Remove duplicate dist entry.

The dist directory is already ignored on line 6. This duplicate entry on line 45 is redundant.

♻️ Proposed fix
-dist
docs/extensions.md (5)

31-47: Add language identifier to TypeScript code block.

Code blocks should specify their language for proper syntax highlighting. Add ts identifier to this code block.

🔧 Proposed fix
-```ts
+```ts
 export type ExtensionDefinition = {

This appears to already have ts, so verify the markdown is rendering correctly.


93-93: Add language identifier to code block (line 93).

This code block should specify a language for syntax highlighting (likely bash or shell).


382-382: Fix hyphenation in SSH tunnel example.

Line 382 uses "ad‑hoc" with a non-standard hyphen character. Use a standard hyphen: ad-hoc.


392-392: Remove redundant phrase in VPN recommendation.

Line 392 uses the redundant phrase "Zero Trust/VPN network". Simplify to just "VPN" or "private network".


403-403: Add language identifiers to JSON code blocks.

Code blocks at lines 403, 417, and 427 are missing language specifications. Add json to enable proper syntax highlighting.

Also applies to: 417-417, 427-427

src/control-plane/extensions/tickets/commands.ts (2)

327-388: Consider extracting status values to a constant.

The status validation at lines 350-353 uses hard-coded string literals. Consider extracting valid status values to a constant or using a type/enum for better maintainability.

♻️ Suggested refactor

At the top of the file, add:

+const VALID_TICKET_STATUSES = ["open", "in_progress", "blocked", "done"] as const
+type TicketStatus = typeof VALID_TICKET_STATUSES[number]

Then update the validation:

-      if (status !== "open" && status !== "in_progress" && status !== "blocked" && status !== "done") {
+      if (!VALID_TICKET_STATUSES.includes(status as TicketStatus)) {
         ctx.logger.error({ message: `Invalid status: ${status}` })
         return 1
       }

And update the error message at line 346 to use the constant:

-        ctx.logger.error({ message: "Usage: hack x tickets status <ticket-id> <open|in_progress|blocked|done>" })
+        ctx.logger.error({ message: `Usage: hack x tickets status <ticket-id> <${VALID_TICKET_STATUSES.join("|")}>` })

575-595: Add error handling for file and stdin reads.

The resolveTicketBody function reads from stdin and files but doesn't handle potential errors. While .catch() is used on line 588, stdin reading at line 581 has no error handling.

🛡️ Add error handling
 async function resolveTicketBody(opts: {
   readonly body?: string
   readonly bodyFile?: string
   readonly bodyStdin: boolean
 }): Promise<string | undefined> {
   if (opts.bodyStdin) {
-    const text = await Bun.stdin.text()
+    const text = await Bun.stdin.text().catch(() => "")
     const trimmed = text.trimEnd()
     return trimmed.length > 0 ? trimmed : undefined
   }
 
   const bodyFile = (opts.bodyFile ?? "").trim()
   if (bodyFile.length > 0) {
-    const text = await Bun.file(bodyFile).text().catch(() => "")
+    try {
+      const text = await Bun.file(bodyFile).text()
+      const trimmed = text.trimEnd()
+      return trimmed.length > 0 ? trimmed : undefined
+    } catch {
+      // Return undefined if file cannot be read
+      return undefined
+    }
   }
 
   const body = (opts.body ?? "").trimEnd()
   return body.length > 0 ? body : undefined
 }
src/control-plane/extensions/tickets/repo-state.ts (1)

119-125: Consider: Negation patterns may need handling.

The filter excludes lines starting with ! (negation patterns), but a negation like !.hack/tickets/important.md would re-include files. The current check might miss cases where .hack/tickets/ is present but partially negated.

This is likely acceptable for the common case, but worth noting if complex gitignore patterns are expected.

src/control-plane/extensions/tickets/store.ts (2)

107-136: Consider: Silent error handling may mask issues.

Line 121 catches all errors when reading event files and returns empty string. If a file exists but is unreadable (permissions), events will be silently skipped. Consider logging a warning via opts.logger.warn when file read fails for non-empty files.

Suggested improvement
     for (const filename of entries.sort()) {
       const path = resolve(eventsDir, filename)
-      const text = await Bun.file(path).text().catch(() => "")
+      let text = ""
+      try {
+        text = await Bun.file(path).text()
+      } catch (err) {
+        opts.logger.warn({ message: `Failed to read events file: ${path}` })
+        continue
+      }
       for (const line of text.split("\n")) {

138-192: Performance note: materializeTickets rereads all events on each call.

Multiple operations (createTicket, listTickets, getTicket, setStatus) each call materializeTickets(), re-reading and re-parsing all event files. For stores with many events, this could be slow.

Consider adding an in-memory cache that invalidates when appendEvents is called, or document that this store is designed for small-scale use.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fb49a67 and 8f69f71.

📒 Files selected for processing (45)
  • .beads/.gitignore
  • .beads/README.md
  • .beads/config.yaml
  • .beads/interactions.jsonl
  • .beads/issues.jsonl
  • .beads/metadata.json
  • .codex/skills/hack-tickets/SKILL.md
  • .gitignore
  • .hack/README.md
  • .hack/docker-compose.yml
  • .hack/hack.config.json
  • AGENTS.md
  • CLAUDE.md
  • README.md
  • docs/architecture.md
  • docs/cli.md
  • docs/extensions.md
  • docs/guides/tickets.md
  • examples/tickets/.hack/README.md
  • examples/tickets/.hack/docker-compose.yml
  • examples/tickets/.hack/hack.config.json
  • examples/tickets/README.md
  • examples/tickets/app.txt
  • src/cli/spec.ts
  • src/commands/internal.ts
  • src/commands/project.ts
  • src/commands/setup.ts
  • src/control-plane/extensions/tickets/agent-docs.ts
  • src/control-plane/extensions/tickets/commands.ts
  • src/control-plane/extensions/tickets/extension.ts
  • src/control-plane/extensions/tickets/repo-state.ts
  • src/control-plane/extensions/tickets/store.ts
  • src/control-plane/extensions/tickets/tickets-git-channel.ts
  • src/control-plane/extensions/tickets/tickets-skill.ts
  • src/control-plane/extensions/tickets/util.ts
  • src/control-plane/sdk/config.ts
  • src/lib/fs.ts
  • src/lib/project.ts
  • src/lib/projects-registry.ts
  • src/templates.ts
  • tests/log-pipe.test.ts
  • tests/mcp.test.ts
  • tests/project-config.test.ts
  • tests/supervisor-service.test.ts
  • tests/tickets-extension.test.ts
💤 Files with no reviewable changes (5)
  • .beads/issues.jsonl
  • .beads/.gitignore
  • .beads/README.md
  • .beads/metadata.json
  • .beads/config.yaml
✅ Files skipped from review due to trivial changes (8)
  • examples/tickets/app.txt
  • examples/tickets/README.md
  • examples/tickets/.hack/README.md
  • examples/tickets/.hack/hack.config.json
  • .codex/skills/hack-tickets/SKILL.md
  • .hack/hack.config.json
  • .hack/README.md
  • .hack/docker-compose.yml
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/cli/spec.ts
  • src/control-plane/extensions/tickets/extension.ts
🧰 Additional context used
🧬 Code graph analysis (4)
src/control-plane/extensions/tickets/tickets-git-channel.ts (2)
src/control-plane/sdk/config.ts (1)
  • TicketsGitConfig (119-119)
src/lib/guards.ts (1)
  • isRecord (1-3)
src/control-plane/sdk/config.ts (4)
src/lib/config-paths.ts (1)
  • resolveGlobalConfigPath (9-13)
src/constants.ts (2)
  • PROJECT_CONFIG_FILENAME (61-61)
  • GLOBAL_ONLY_EXTENSION_IDS (38-41)
src/lib/fs.ts (1)
  • readTextFile (21-27)
src/lib/guards.ts (1)
  • isRecord (1-3)
src/commands/setup.ts (2)
src/cli/options.ts (1)
  • optPath (3-10)
src/cli/command.ts (4)
  • CommandArgs (61-71)
  • defineCommand (108-110)
  • withHandler (125-130)
  • CliContext (33-36)
src/commands/project.ts (4)
src/lib/fs.ts (2)
  • readTextFile (21-27)
  • pathExists (3-15)
src/lib/guards.ts (1)
  • isRecord (1-3)
src/constants.ts (4)
  • GLOBAL_HACK_DIR_NAME (25-25)
  • GLOBAL_CADDY_DIR_NAME (32-32)
  • GLOBAL_CADDY_COMPOSE_FILENAME (45-45)
  • DEFAULT_INGRESS_NETWORK (4-4)
src/lib/shell.ts (1)
  • exec (13-31)
🪛 GitHub Actions: CI
src/commands/internal.ts

[error] 16-16: Type 'null' is not assignable to type 'string | undefined'.


[error] 26-26: Type '"Internal"' is not assignable to type 'CliGroup'.


[error] 38-38: Type '"Internal"' is not assignable to type 'CliGroup'.


[error] 47-47: Type '"Internal"' is not assignable to type 'CliGroup'.


[error] 56-56: Type '"Internal"' is not assignable to type 'CliGroup'.


[error] 64-64: Type '"Internal"' is not assignable to type 'CliGroup'.


[error] 77-77: Property 'trim' does not exist on type 'string | number | boolean'.


[error] 131-131: Type 'readonly ["hostname", "target"]' does not satisfy the constraint 'readonly PositionalSpec[]'.


[error] 136-136: Property 'hostname' does not exist on type '{}'.


[error] 137-137: Property 'target' does not exist on type '{}'.


[error] 159-159: Type 'readonly ["hostname"]' does not satisfy the constraint 'readonly PositionalSpec[]'.


[error] 164-164: Property 'hostname' does not exist on type '{}'.

🪛 LanguageTool
CLAUDE.md

[grammar] ~44-~44: Ensure spelling is correct
Context: ... leveraging generics and smart types to sensure the best possible tpye inference across...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~44-~44: Ensure spelling is correct
Context: ...mart types to sensure the best possible tpye inference across the project. <!-- hac...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

docs/cli.md

[style] ~271-~271: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...e combined with --pretty. - --until cannot be combined with --follow. ### hack ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

docs/extensions.md

[grammar] ~382-~382: Ensure spelling is correct
Context: ...xpose it through one of: - SSH tunnel (ad‑hoc): ssh -L 7788:127.0.0.1:7788 <host> -...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~392-~392: This phrase is redundant (‘N’ stands for ‘network’). Use simply “VPN”.
Context: ...access. 2) Move to a private Zero Trust/VPN network once you want persistent access. 3) Add...

(ACRONYM_TAUTOLOGY)

🪛 markdownlint-cli2 (0.18.1)
docs/cli.md

61-61: Table column count
Expected: 2; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


740-740: Table column count
Expected: 4; Actual: 7; Too many cells, extra data will be missing

(MD056, table-column-count)


762-762: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


780-780: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


852-852: Table column count
Expected: 4; Actual: 6; Too many cells, extra data will be missing

(MD056, table-column-count)


853-853: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


905-905: Table column count
Expected: 4; Actual: 7; Too many cells, extra data will be missing

(MD056, table-column-count)

docs/extensions.md

93-93: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


142-142: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


403-403: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


417-417: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


427-427: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🔇 Additional comments (73)
src/lib/project.ts (3)

163-178: LGTM!

The extraHosts field is well-documented with a clear use case example for host-local tunnels and TLS SNI preservation. The Record<string, string> type appropriately models Docker Compose extra_hosts entries.


285-301: LGTM!

The integration of extraHosts into parseInternalConfig follows the established pattern used for dns and tls. The conditional spread and empty-object check maintain consistency with other config parsers in this file.


303-317: LGTM!

The parseStringMap function properly validates both keys and values, trimming whitespace and filtering out empty or non-string entries. The pattern matches readInternalExtraHosts in src/commands/internal.ts, ensuring consistent parsing behavior across config sources.

src/commands/internal.ts (1)

183-198: LGTM!

The list handler correctly resolves the project, reads the extra-hosts map, and outputs it as formatted JSON to stdout. The implementation follows the established patterns in the file.

src/lib/projects-registry.ts (2)

138-164: LGTM! Well-designed ID-based project resolver.

The implementation correctly mirrors resolveRegisteredProjectByName with the intentional enhancement of returning both the registration metadata and the ProjectContext. This provides callers with complete project information in a single lookup, which is useful for operations that need both the registration details and the resolved project paths.


229-235: No changes needed.

The implementation is correct and the semantics are clear: HACK_GLOBAL_CONFIG_PATH is intended to be a file path (as documented in docs/sdk.md), and dirname() correctly extracts the directory containing that config file to serve as the registry root. All tests confirm this pattern by setting the env var to file paths like hack-global-config-*.json.

Likely an incorrect or invalid review comment.

examples/tickets/.hack/docker-compose.yml (1)

1-5: LGTM!

This minimal Docker Compose setup serves as a valid placeholder for the tickets example. The noop service provides a simple demonstration environment.

AGENTS.md (2)

46-59: LGTM!

The tickets documentation is clear and provides helpful command examples for the new git-backed tickets system.


65-65: LGTM!

The terminology change from "issues" to "tickets" is consistent with the new tickets system introduced in this PR.

src/lib/fs.ts (1)

3-15: Verify cross-runtime compatibility behavior.

The new logic provides a fallback to node:fs stat when Bun.file().stat is not available, enabling cross-runtime compatibility. However, if Bun.file().stat() exists but throws an error (e.g., permission denied), the code will skip the Node.js fallback and return false immediately.

Is this the intended behavior? Or should the fallback be attempted when Bun.file().stat() throws, not just when it doesn't exist?

docs/guides/tickets.md (1)

1-128: Approve documentation for tickets extension guide.

The guide is well-structured, practical, and clearly explains the tickets extension's purpose, setup, usage, and storage layout. Examples are helpful and configuration instructions are clear.

src/control-plane/extensions/tickets/util.ts (4)

1-3: LGTM!

Standard Unix timestamp conversion is implemented correctly.


5-10: LGTM!

Good use of UTC methods for consistent month stamp formatting across timezones.


12-15: LGTM!

Ticket ID formatting is straightforward and correct.


26-43: LGTM!

The deterministic JSON serialization is implemented correctly, with proper recursion for nested structures.

src/commands/project.ts (5)

548-575: LGTM!

Safe JSON parsing with appropriate validation and error handling. Empty object fallback ensures the function is safe to use without additional null checks.


600-703: LGTM!

The updated signature and implementation correctly integrate branch-aware host mappings and extra_hosts. The separation of managed (Caddy-derived) and user-configured extra hosts is well-designed.


734-762: LGTM!

Correctly extracts Caddy hosts from compose service labels with proper error handling and deterministic output.


764-781: LGTM!

Robust parsing that correctly filters out dynamic patterns, templates, and ports to extract only static hostnames suitable for extra_hosts mappings.


855-894: LGTM!

The Caddy IP resolution follows the same safe pattern as CoreDNS resolution, with appropriate fallbacks and error handling.

src/control-plane/extensions/tickets/agent-docs.ts (4)

5-29: LGTM!

Type definitions and marker constants are well-defined and appropriate for the use case.


31-54: LGTM!

Robust batch processing with per-target error handling and appropriate status tracking.


56-116: LGTM!

Check and remove functions handle all edge cases correctly with appropriate status codes.


151-183: LGTM!

Snippet manipulation helpers correctly handle insertion, replacement, and removal with proper content preservation and formatting.

src/templates.ts (2)

349-353: LGTM!

The extra_hosts schema correctly models a hostname-to-IP mapping.


364-442: LGTM!

The control plane schema is well-structured with appropriate types and extensibility via additionalProperties. The optional nature aligns with the design goals.

docs/architecture.md (3)

19-20: LGTM!

Documentation accurately describes the CoreDNS and extra_hosts integration, including the rationale for resolver compatibility and operational guidance.

Also applies to: 84-96


181-213: LGTM!

Clear documentation of the control plane architecture with helpful visualization and appropriate cross-references to detailed API documentation.


141-144: LGTM!

Retention documentation clearly explains the two-level configuration system and when it applies.

src/commands/setup.ts (3)

8-8: LGTM: Tickets skill integration follows established patterns.

The import, options constant, and type definition are consistent with the existing setup commands (cursor, claude, codex).

Also applies to: 84-84, 100-100


169-169: LGTM: Tickets subcommand properly wired.

The tickets subcommand is correctly integrated into the setup command using the established pattern.


250-273: LGTM: Handler implementation is correct and consistent.

The handleSetupTickets function follows the same pattern as the other setup handlers, with appropriate error handling and result logging.

src/control-plane/extensions/tickets/commands.ts (6)

20-138: LGTM: Setup command implementation is thorough.

The setup command properly handles:

  • Project validation
  • Argument parsing
  • Repository state management (gitignore, tracking)
  • Interactive and non-interactive flows
  • JSON output
  • Error handling

The conditional logic for TTY-based prompts is well-structured.


139-204: LGTM: Create command has proper validation and setup checks.

The create command:

  • Validates required title parameter
  • Ensures tickets setup is complete before creating
  • Resolves body from multiple sources (stdin, file, inline)
  • Provides both JSON and formatted output

206-253: LGTM: List command implementation is clean.

Straightforward list implementation with proper empty state handling and formatted table output.


255-325: LGTM: Show command provides comprehensive ticket details.

The show command properly displays ticket metadata, body content, and event history with appropriate formatting.


390-439: LGTM: Sync command implementation is straightforward.

The sync command properly delegates to the store and displays sync status clearly.


597-738: LGTM: Setup validation and argument parsing are well-implemented.

The maybeEnsureTicketsSetup function:

  • Comprehensively checks setup requirements
  • Provides interactive fix flow with TTY detection
  • Falls back to informative warnings for non-interactive contexts

The parseTicketsSetupArgs function properly parses all setup-specific flags with appropriate validation.

README.md (5)

120-130: LGTM: Clear documentation for extra_hosts feature.

The new documentation clearly explains how to manage dynamic extra_hosts mappings and where they are stored.


157-168: LGTM: Commands section updated with new functionality.

The updated commands section provides a good high-level overview and properly references detailed documentation.


208-300: LGTM: Comprehensive control plane and gateway documentation.

The new section provides:

  • Clear explanation of the extension system
  • Detailed gateway setup instructions
  • Security guidance for tokens and write access
  • Multiple remote access options with trade-offs
  • Good warnings about appropriate use cases

397-426: LGTM: Improved service-to-service connection guidance.

The updated section provides clearer guidance on:

  • When to use *.hack hostnames (HTTP services)
  • When to use Compose service names (non-HTTP services)
  • How the internal DNS and extra_hosts work together
  • Troubleshooting for ENOTFOUND errors

720-726: LGTM: Troubleshooting section updated for new architecture.

The troubleshooting updates reflect the CoreDNS and extra_hosts changes with specific commands to resolve issues.

src/control-plane/extensions/tickets/tickets-skill.ts (4)

1-17: LGTM: Type definitions and constants are well-structured.

The type definitions clearly specify the scope, status values, and result shape. Constants follow established patterns.


19-67: LGTM: Install and check functions are robust.

The install function:

  • Properly resolves paths based on scope
  • Avoids unnecessary file writes with writeTextFileIfChanged
  • Returns appropriate status values

The check function validates not just file presence but also content integrity via marker check.


69-92: LGTM: Remove function properly cleans up skill directory.

The function removes the entire skill directory recursively and handles the case where the skill is already absent.


94-175: LGTM: Skill content rendering and path resolution are well-implemented.

The renderTicketsSkill function:

  • Generates comprehensive skill documentation
  • Uses proper markdown frontmatter
  • Documents all commands and configuration

The path resolution:

  • Properly validates required inputs (project root, HOME)
  • Provides clear error messages
  • Handles both user and project scopes correctly
src/control-plane/extensions/tickets/tickets-git-channel.ts (8)

1-27: LGTM: Interface design is clean and follows result pattern.

The TicketsGitChannel interface clearly defines the contract with appropriate result types using the ok/error pattern.


67-88: LGTM: Bare repo initialization with excellent documentation.

The critical comment at lines 77-80 explains why git init --bare is used instead of cloning:

"Important: do NOT clone --bare the project. This channel is intended to store only .hack/tickets/** on a dedicated branch."

This prevents the tickets repo from becoming enormous and keeps it focused on ticket data only.


116-187: LGTM: Checkout logic comprehensively handles all scenarios.

The checkoutHead function properly handles:

  • Remote-tracked branches (fetch, checkout, reset)
  • Missing remote refs (falls back to local)
  • Local branches (checkout, reset)
  • Fresh repos (orphan branch with initial commit)

The error handling at each step is appropriate and provides clear messages.


204-254: LGTM: Event writing with proper deduplication.

The event writing logic:

  • Organizes events by month for better file management
  • Deduplicates by eventId to prevent duplicates
  • Properly appends to existing files with newline handling
  • Normalizes logs after writing to maintain consistency

256-301: LGTM: Log normalization ensures data consistency.

The normalization function:

  • Deduplicates events across all files
  • Maintains stable sort order (timestamp, then eventId)
  • Only rewrites files when content has changed
  • Gracefully handles missing directories

This ensures the event log remains consistent across sync operations.


322-348: Push retry logic is well-implemented but limited to one retry.

The pushWithRetry function:

  • Attempts push once
  • On failure, fetches latest, re-checks out, rewrites pending events, and retries
  • Only retries once (no infinite loop)

This is a good balance between reliability and avoiding infinite retries. The single retry handles most common cases like concurrent pushes.


350-411: LGTM: Public API properly composes internal helpers.

The public methods:

  • ensureCheckedOut: throws on error for caller simplicity
  • appendEvents: full workflow with pending event retry
  • sync: normalization-focused workflow

The composition is clean and each method has a clear purpose.


413-444: LGTM: Utility functions are well-implemented.

The utilities provide:

  • runGit: clean git command execution via Bun.spawn
  • safeJsonParse: safe JSON parsing with error handling
  • isMissingRemoteRef: robust detection of missing remote refs
src/control-plane/sdk/config.ts (7)

1-9: LGTM!

Imports are well-organized and necessary for the configuration system.


10-116: LGTM! Schema definitions follow Zod 4 best practices.

The Input/Output schema pattern allows flexible partial input while enforcing complete output with sensible defaults. Using .default() with pre-parsed values for nested objects is correct for Zod 4.


118-126: LGTM!

Type exports are clean and provide the necessary public API surface.


128-160: LGTM!

The config loading flow is clear: read both layers, merge, and aggregate errors. The conditional inclusion of parseError in the return is a nice touch.


206-228: Verify: Gateway merge may discard project-level settings other than enabled.

After mergeRecords merges all config sections, line 225 overwrites merged.gateway with only globalGateway plus enabled. This discards any project-level gateway settings (e.g., port, bind, allowWrites) that were merged earlier.

If this is intentional (global-only gateway settings for security), consider adding a comment. Otherwise, the merge should preserve project settings:

Suggested fix if project settings should be preserved
   const globalGateway = isRecord(opts.global.gateway) ? opts.global.gateway : {}
   const projectGateway = isRecord(opts.project.gateway) ? opts.project.gateway : {}
   const projectEnabled = projectGateway.enabled
   const gatewayEnabled = typeof projectEnabled === "boolean" ? projectEnabled : false
-  merged.gateway = { ...globalGateway, enabled: gatewayEnabled }
+  // Gateway inherits from global, project can only toggle `enabled`
+  merged.gateway = { ...merged.gateway, enabled: gatewayEnabled }

230-251: LGTM!

The mergeExtensions function correctly handles GLOBAL_ONLY_EXTENSION_IDS by restoring global values or removing project-only definitions. This prevents projects from overriding sensitive global-only extensions.


253-277: LGTM!

mergeRecords performs recursive shallow merge correctly, skipping undefined values. joinParseErrors cleanly aggregates errors with a separator.

src/control-plane/extensions/tickets/repo-state.ts (5)

1-35: LGTM!

Type definitions are comprehensive and cover all status variations. Constants for gitignore are appropriately scoped to this module.


37-66: LGTM!

The state checking logic is solid. Using -z flag with ls-files for NUL-delimited output is a good practice for handling filenames with special characters.


68-85: LGTM!

The function correctly handles three cases: entry already present, file doesn't exist (create), and file exists without entry (update).


87-117: LGTM!

The untrack flow is correct: check if tracked, then git rm --cached to remove from index without deleting files. The -r flag handles recursive removal correctly.


142-161: LGTM!

The runGit helper properly handles process spawning, output capture, and error cases. Using stdin: "ignore" prevents hanging on interactive prompts.

src/control-plane/extensions/tickets/store.ts (6)

1-11: LGTM!

Imports are appropriate for the event-sourced ticket store functionality.


13-51: LGTM!

Type definitions are well-structured. Using discriminated unions for result types (ok: true | false) is a good pattern for explicit error handling.


175-188: Note: Clearing ticket body is not possible.

In ticket.updated handling, if payload.body is not a string (e.g., null or undefined), body becomes undefined and the spread on line 185 won't include it, leaving the existing body unchanged.

If intentional (body can only be set, never cleared), this is fine. If clearing should be supported, consider checking for explicit null:

-        const body = typeof event.payload["body"] === "string" ? event.payload["body"] : undefined
+        const body = event.payload["body"] === null ? "" : 
+                     typeof event.payload["body"] === "string" ? event.payload["body"] : undefined

223-253: LGTM!

The createTicket implementation correctly builds and persists the event, then returns a constructed summary without re-reading from storage.


255-277: LGTM!

List, get, and sync operations are straightforward. Sorting tickets by numeric ID provides consistent ordering.


288-313: LGTM!

parseEvent robustly validates all required fields and recomputes tsIso from ts for consistency. Returning null for invalid events is appropriate for the forgiving read pattern.

Comment thread docs/cli.md
Comment thread docs/cli.md
| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `-p`, `--path <dir>` | string | - | Run against a repo path (overrides cwd search) |
| `-c`, `--client <cursor|claude|codex|print>` | string | - | Open init prompt in an agent client (or print) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix malformed table structure in hack setup cursor options.

The table at line 740 has inconsistent column count (expected 4, got 7). The header row should align with the data cells. Check if columns are being split unexpectedly, possibly due to pipe characters in cell content.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

740-740: Table column count
Expected: 4; Actual: 7; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
In @docs/cli.md at line 740, The table row for the `-c`, `--client
<cursor|claude|codex|print>` option is producing extra pipe separators and
breaking the 4-column table structure; fix it by ensuring the entire option cell
is a single cell (escape or wrap the inner pipes such as
`<cursor|claude|codex|print>` so they don't split columns) and align it with the
existing header columns, keeping the row to exactly four pipe-separated cells;
update the offending row where the `-c`, `--client <cursor|claude|codex|print>`
entry appears to use proper escaping or quoting so the table parser treats the
value as one cell.

Comment thread docs/cli.md
Comment thread docs/cli.md
Comment on lines +852 to +853
| `--format <auto|docker-compose|plain>` | string | `auto` | How to parse incoming lines from stdin |
| `--stream <stdout|stderr>` | string | `stdout` | Treat stdin as stdout or stderr |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix malformed table structure in hack log-pipe options.

The tables at lines 852–853 have column count mismatches. Line 852 shows expected 4 but has 6 cells; line 853 shows expected 4 but has 5 cells. Verify table alignment and cell boundaries.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

852-852: Table column count
Expected: 4; Actual: 6; Too many cells, extra data will be missing

(MD056, table-column-count)


853-853: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
In @docs/cli.md around lines 852 - 853, The two Markdown table rows for the hack
log-pipe options are malformed (extra pipe separators) causing column-count
mismatches; locate the rows containing `--format <auto|docker-compose|plain>`
and `--stream <stdout|stderr>` and edit each so they have exactly four
pipe-delimited cells matching the table header (Option | Type | Default |
Description), removing any stray pipes or merged cells, keep backticks around
option and default values, and verify the table alignment renders correctly.

Comment thread docs/cli.md
Comment thread src/commands/internal.ts
Comment thread src/commands/internal.ts
Comment thread src/commands/project.ts
isCancel,
multiselect,
note,
select,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

@clack/prompts latest stable version and 1.0.0-alpha.9 release status

💡 Result:

  • Latest stable (npm "latest" tag): 0.11.0. [1][2]
  • 1.0.0-alpha.9: I cannot find an official npm release/page for 1.0.0-alpha.9. Yarn’s package page shows the alpha line up to 1.0.0-alpha.6 (Oct 3, 2025), while Snyk’s metadata references 1.0.0-alpha.9 as a “latest non vulnerable” entry (not an npm registry page). This suggests alpha.9 is not published or not tagged on the public npm registry in a way that npm/yarn list it. [3][4][5]

Sources:

  1. npm package @clack/prompts (latest: 0.11.0). [npm page]. [1]
  2. npm versions / tags view for @clack/prompts. [2]
  3. Yarn classic package page (alpha tag shows 1.0.0-alpha.6). [3]
  4. Snyk package metadata referencing 1.0.0-alpha.9. [4]
  5. Bombshell / Clack docs (project pages). [5]

🏁 Script executed:

fd -type f -name "package.json" -o -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" | head -20

Repository: hack-dance/hack-cli

Length of output: 233


🏁 Script executed:

if [ -f "package.json" ]; then
  cat package.json | grep -A 2 -B 2 "@clack/prompts" || echo "No @clack/prompts found in package.json"
fi

Repository: hack-dance/hack-cli

Length of output: 247


🏁 Script executed:

if [ -f "package-lock.json" ]; then
  cat package-lock.json | grep -A 5 '"@clack/prompts"' | head -20 || echo "No @clack/prompts in package-lock.json"
fi

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

cat package.json | grep -A 30 -B 5 '"@clack/prompts"' | head -50

Repository: hack-dance/hack-cli

Length of output: 348


🏁 Script executed:

if [ -f "package.json" ]; then
  cat package.json | grep -E '"(resolutions|overrides|pnpm)' -A 20
fi

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

find . -maxdepth 2 -name ".npmrc" -o -name ".yarnrc" -o -name ".yarnrc.yml" -o -name "pnpm-workspace.yaml" | xargs cat 2>/dev/null

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

git config --get-all --local --global --system remote.origin.url 2>/dev/null || echo "Not a git repo or no origin"

Repository: hack-dance/hack-cli

Length of output: 90


🏁 Script executed:

if [ -f "yarn.lock" ]; then
  grep -A 10 "@clack/prompts@1.0.0-alpha.9" yarn.lock || grep -A 10 "@clack/prompts" yarn.lock | head -20
fi

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

if [ -f "pnpm-lock.yaml" ]; then
  grep -A 10 "@clack/prompts" pnpm-lock.yaml | head -20
fi

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

# Check what actually exists in node_modules if available
if [ -d "node_modules/@clack/prompts" ]; then
  cat node_modules/@clack/prompts/package.json | grep -E '"name"|"version"'
fi

Repository: hack-dance/hack-cli

Length of output: 45


@clack/prompts version 1.0.0-alpha.9 does not exist on the npm registry.

The specified version appears to be unpublished or unavailable. The latest published alpha version is 1.0.0-alpha.6, and the stable release is 0.11.0. This dependency will likely fail to install. Confirm whether this version should be a git reference, a private registry version, or if it should be updated to an available version.

🤖 Prompt for AI Agents
In @src/commands/project.ts at line 7, The package version for @clack/prompts
referenced by the import (e.g., the symbol select imported from
'@clack/prompts') is invalid (1.0.0-alpha.9) and will fail to install; update
package.json to point to a published version (for example 1.0.0-alpha.6 or the
stable 0.11.0) or convert it to the correct git/private-registry reference, then
reinstall so the import of select resolves correctly.

Comment thread src/commands/setup.ts
Comment on lines +131 to +138
const ticketsSpec = defineCommand({
name: "tickets",
summary: "Install Codex skill for hack tickets usage",
group: "Agents",
options: setupTicketsOptions,
positionals: [],
subcommands: []
} as const)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the summary to reflect tickets, not Codex.

The summary currently reads "Install Codex skill for hack tickets usage", but this appears to be copied from the codexSpec definition. It should describe the tickets functionality specifically.

📝 Suggested fix
 const ticketsSpec = defineCommand({
   name: "tickets",
-  summary: "Install Codex skill for hack tickets usage",
+  summary: "Install Tickets skill for tracking work items in-repo",
   group: "Agents",
   options: setupTicketsOptions,
   positionals: [],
   subcommands: []
 } as const)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ticketsSpec = defineCommand({
name: "tickets",
summary: "Install Codex skill for hack tickets usage",
group: "Agents",
options: setupTicketsOptions,
positionals: [],
subcommands: []
} as const)
const ticketsSpec = defineCommand({
name: "tickets",
summary: "Install Tickets skill for tracking work items in-repo",
group: "Agents",
options: setupTicketsOptions,
positionals: [],
subcommands: []
} as const)
🤖 Prompt for AI Agents
In @src/commands/setup.ts around lines 131 - 138, The summary on the ticketsSpec
command is incorrect (it references Codex); update the summary field in the
ticketsSpec definition to describe the tickets feature (e.g., "Install Tickets
skill for hack ticket management" or similar) so it accurately reflects the
tickets functionality instead of copying codexSpec text.

Comment on lines +194 to +202
const computeNextTicketId = async (): Promise<string> => {
const tickets = await materializeTickets()
let max = 0
for (const ticketId of tickets.keys()) {
const n = parseTicketNumber(ticketId)
if (n !== null && n > max) max = n
}
return formatTicketId(max + 1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition risk in ticket ID generation.

computeNextTicketId reads all tickets to determine the next ID, but between computing the ID and appending the event (line 237), another concurrent process could create a ticket with the same ID. This would result in duplicate ticket IDs.

For a single-user CLI this may be acceptable, but for multi-process/multi-user scenarios, consider:

  1. Using a lock file during ticket creation
  2. Using a counter file with atomic increment
  3. Including a UUID suffix as a tiebreaker
🤖 Prompt for AI Agents
In @src/control-plane/extensions/tickets/store.ts around lines 194 - 202,
computeNextTicketId currently scans materializeTickets, uses parseTicketNumber
and formatTicketId, then code later appends a ticket event — this allows races
and duplicate IDs when concurrent processes create tickets; fix by making ID
generation atomic: either (A) add a dedicated counter store and replace
computeNextTicketId to perform an atomic increment (e.g., increment-and-read a
counter file/DB entry) so each process gets a unique sequential ID, or (B)
acquire a process-wide lock around computeNextTicketId + the append step (use a
lock file or mutex) to serialize creation, or (C) append a collision-safe suffix
(e.g., a short UUID or timestamp) to the value returned by formatTicketId to
guarantee uniqueness; update all callers of computeNextTicketId and the
ticket-id format logic so parsing/formatting remains consistent with the chosen
approach.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @src/control-plane/extensions/tickets/store.ts:
- Around line 273-308: createTicket currently computes ticketId via
computeNextTicketId and can produce collisions under concurrency; wrap the ID
allocation+append in a distributed lock (or call out to a centralized allocator)
so only one process can reserve the next ID at a time: acquire a lock keyed by
the project/namespace before calling computeNextTicketId, build the event and
call git.appendEvents, then release the lock (handle failures by releasing and
retrying). Alternatively, replace computeNextTicketId with a central allocator
service you call from createTicket. Ensure any retry-after-fetch logic still
verifies ticketId uniqueness (not just eventId) by checking existing tickets for
the allocated ticketId before returning success; reference createTicket,
computeNextTicketId, git.appendEvents and the retry/apply logic that currently
deduplicates by eventId.

In @src/control-plane/extensions/tickets/util.ts:
- Around line 17-24: The parseTicketNumber function currently allows negative
values like "T--5"; update parseTicketNumber to validate the parsed numeric
value is non-negative before returning: after converting rest to Number and
confirming Number.isFinite(n), check that n >= 0 (or Math.trunc(n) >= 0) and
return null for negatives; keep existing trimming, "T-" prefix check, finite
check, and return Math.trunc(n) only when the value is non-negative.
- Around line 12-15: formatTicketId currently assumes n is a non-negative
integer; add input validation at the top of the function (formatTicketId) to
ensure Number.isInteger(n) and n >= 0 and throw a clear TypeError if not, so
negatives, floats, NaN or non-numbers are rejected and malformed IDs like
"T--0005" or "T-1.23" can't be produced; keep the existing padding logic after
the guard.
🧹 Nitpick comments (5)
src/control-plane/extensions/tickets/util.ts (1)

42-58: Consider making the fallback explicit or removing it.

Lines 52-53 use ?? 0 as a fallback when parseTicketNumber returns null. Since all values in out come from formatTicketId (which produces valid "T-XXXXX" format), parseTicketNumber should never return null here. The fallback could mask bugs.

♻️ Option 1: Remove the fallback and assert validity
   out.sort((a, b) => {
-    const an = parseTicketNumber(a) ?? 0
-    const bn = parseTicketNumber(b) ?? 0
+    const an = parseTicketNumber(a)!
+    const bn = parseTicketNumber(b)!
     if (an !== bn) return an - bn
     return a.localeCompare(b)
   })

Option 2: Keep the fallback but add a runtime check:

   out.sort((a, b) => {
     const an = parseTicketNumber(a) ?? 0
     const bn = parseTicketNumber(b) ?? 0
+    if (an === 0) throw new Error(`Failed to parse normalized ticket: ${a}`)
+    if (bn === 0) throw new Error(`Failed to parse normalized ticket: ${b}`)
     if (an !== bn) return an - bn
     return a.localeCompare(b)
   })
src/control-plane/extensions/tickets/store.ts (2)

242-251: Performance: computeNextTicketId reads all events on every ticket creation.

The computeNextTicketId function materializes all tickets from the entire event log just to find the maximum ticket ID. For projects with many tickets, this could become a performance bottleneck during ticket creation.

Consider caching the last ticket ID or maintaining it in a separate, lightweight file that's updated atomically.


96-101: The hostname-based actor fallback is a standard pattern but consider privacy requirements for event tracking.

The resolveActor function uses hostname() only as a final fallback when no explicit actor is provided and the USER environment variable is unavailable. While this pattern is common for audit trails in event-sourced systems, hostnames can contain sensitive information. If your privacy requirements restrict exposing machine identifiers in stored events, consider alternatives such as using a generic identifier or requiring explicit actor specification in this context.

src/control-plane/extensions/tickets/commands.ts (1)

658-805: Consider refactoring the argument parser for better maintainability.

The parseTicketsArgs function is 147 lines long with repetitive patterns for handling different flags. While functional, this approach makes it harder to maintain and extend.

Consider using a more declarative approach, such as a configuration-driven parser or a library like commander or yargs, which would reduce duplication and make the argument schema more explicit.

src/control-plane/extensions/tickets/agent-docs.ts (1)

118-150: Consider: Hardcoded documentation might become stale.

The renderTicketsAgentDocsSnippet function returns a static markdown string with command examples. If command signatures change in the future, this documentation would need manual updates.

While this is a minor concern and acceptable for agent documentation, consider whether generating examples dynamically from command metadata would provide better consistency.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8f69f71 and 672cf6c.

📒 Files selected for processing (12)
  • .codex/skills/hack-tickets/SKILL.md
  • src/control-plane/extensions/tickets/agent-docs.ts
  • src/control-plane/extensions/tickets/commands.ts
  • src/control-plane/extensions/tickets/store.ts
  • src/control-plane/extensions/tickets/tickets-skill.ts
  • src/control-plane/extensions/tickets/util.ts
  • src/tui/tickets-tui.ts
  • tests/gateway-e2e.test.ts
  • tests/helpers/ci.ts
  • tests/log-pipe.test.ts
  • tests/mcp.test.ts
  • tests/tickets-extension.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/control-plane/extensions/tickets/tickets-skill.ts
  • .codex/skills/hack-tickets/SKILL.md
🧰 Additional context used
🧬 Code graph analysis (3)
src/control-plane/extensions/tickets/agent-docs.ts (1)
src/lib/fs.ts (3)
  • pathExists (3-15)
  • readTextFile (21-27)
  • writeTextFileIfChanged (33-41)
src/control-plane/extensions/tickets/commands.ts (9)
src/control-plane/extensions/types.ts (2)
  • ExtensionCommand (24-33)
  • ExtensionCommandContext (15-22)
src/ui/terminal.ts (1)
  • isTty (6-8)
src/ui/gum.ts (1)
  • isGumAvailable (53-55)
src/control-plane/extensions/tickets/tickets-skill.ts (2)
  • checkTicketsSkill (45-67)
  • installTicketsSkill (19-43)
src/control-plane/extensions/tickets/agent-docs.ts (3)
  • checkTicketsAgentDocs (56-84)
  • removeTicketsAgentDocs (86-116)
  • upsertTicketsAgentDocs (31-54)
src/ui/display.ts (1)
  • display (237-261)
src/control-plane/extensions/tickets/store.ts (1)
  • createTicketsStore (54-378)
src/tui/tickets-tui.ts (1)
  • runTicketsTui (66-911)
src/control-plane/extensions/tickets/util.ts (2)
  • normalizeTicketRef (26-40)
  • normalizeTicketRefs (42-58)
src/control-plane/extensions/tickets/store.ts (3)
src/control-plane/extensions/tickets/tickets-git-channel.ts (1)
  • createGitTicketsChannel (28-411)
src/control-plane/extensions/tickets/util.ts (4)
  • unixSeconds (1-3)
  • parseTicketNumber (17-24)
  • formatTicketId (12-15)
  • normalizeTicketRefs (42-58)
src/lib/guards.ts (1)
  • isRecord (1-3)
🔇 Additional comments (14)
src/control-plane/extensions/tickets/util.ts (5)

1-3: LGTM!

The implementation correctly converts milliseconds to Unix seconds.


5-10: LGTM!

The function correctly converts Unix timestamps to UTC-based YYYY-MM format with proper month indexing and zero-padding.


26-40: Logic is sound.

The normalization logic correctly handles various input formats (#T-123, T-123, 123) and produces canonical output. The function appropriately leverages parseTicketNumber and formatTicketId for consistency.


60-62: LGTM!

Clean wrapper for producing stable JSON output.


64-77: LGTM!

Standard and correct implementation of recursive object key sorting for stable JSON serialization.

src/control-plane/extensions/tickets/store.ts (3)

380-384: LGTM: Robust dependency parsing.

The parseDependencyList function safely filters for string types and normalizes ticket references, providing good resilience against malformed data.


386-394: LGTM: Correct use of hasOwnProperty.

Using Object.prototype.hasOwnProperty.call is the correct and safe way to check for property existence, avoiding issues with objects that might have a custom hasOwnProperty property.


396-417: LGTM: Derived blocks computation is sound.

The applyDerivedBlocks function correctly computes reverse dependencies by iterating through all tickets' dependsOn arrays and adding the dependent ticket to each dependency's blocks array. The use of normalizeTicketRefs ensures deduplication and sorting.

src/control-plane/extensions/tickets/commands.ts (3)

807-832: Verify: Reading entire stdin into memory for ticket bodies.

Line 815 reads all of stdin into memory with Bun.stdin.text(). For typical ticket body content, this is acceptable. However, if users accidentally pipe large files or streams, this could cause memory issues.

Consider whether a size limit or streaming approach would be beneficial, or document the expected input size.


22-624: LGTM: Well-structured command handlers with comprehensive error handling.

The command implementations demonstrate good practices:

  • Consistent project context validation
  • Clear error messages with usage examples
  • Support for both interactive and JSON output modes
  • Proper separation of concerns (parsing, validation, business logic, output)

The maybeEnsureTicketsSetup helper is a nice touch that guides users through incomplete setup.


84-100: Good UX: Interactive prompts with sensible fallbacks.

The setup command correctly checks for TTY and Gum availability before prompting interactively, and provides clear instructions for non-interactive environments. The default of true for untracking is appropriate since it's the safer option.

src/control-plane/extensions/tickets/agent-docs.ts (3)

31-54: LGTM: Efficient upsert implementation with minimal writes.

The upsertTicketsAgentDocs function optimally renders the snippet once and uses writeTextFileIfChanged to avoid unnecessary disk writes. The per-target error handling ensures one failure doesn't affect other targets.


163-180: LGTM: upsertSnippet correctly preserves surrounding content.

The function properly handles both updating existing snippets (lines 168-174) and appending new ones (lines 176-179). The use of trimEnd() and trimStart() ensures clean formatting, and filtering out empty strings prevents extra blank lines.


182-195: LGTM: Clean snippet removal with content preservation.

The removeSnippet function correctly extracts and rejoins content around the marked snippet, handling edge cases like missing markers or empty results. The final formatting ensures the file ends with a single newline.

Comment on lines +273 to +308
createTicket: async input => {
const ticketId = await computeNextTicketId()
const dependsOn = normalizeTicketRefs(input.dependsOn ?? [])
const blocks = normalizeTicketRefs(input.blocks ?? [])
const event = buildEvent({
ticketId,
type: "ticket.created",
payload: {
title: input.title,
...(input.body ? { body: input.body } : {}),
...(dependsOn.length > 0 ? { dependsOn } : {}),
...(blocks.length > 0 ? { blocks } : {}),
status: "open"
},
actor: input.actor
})

const wrote = await git.appendEvents({ events: [event] })
if (!wrote.ok) return wrote

return {
ok: true,
ticket: {
ticketId,
title: input.title,
...(input.body ? { body: input.body } : {}),
status: "open",
createdAt: event.tsIso,
updatedAt: event.tsIso,
dependsOn,
blocks,
...(opts.projectId ? { projectId: opts.projectId } : {}),
...(opts.projectName ? { projectName: opts.projectName } : {})
}
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find where computeNextTicketId is defined
rg -n "computeNextTicketId" src/control-plane/extensions/tickets/ --type=ts -B 2 -A 5

Repository: hack-dance/hack-cli

Length of output: 1446


🏁 Script executed:

# Check for any lock or concurrency mechanisms in the tickets directory
rg -n "lock|mutex|semaphore|concurrent" src/control-plane/extensions/tickets/ --type=ts

Repository: hack-dance/hack-cli

Length of output: 6746


🏁 Script executed:

# Look at the store.ts file structure to find computeNextTicketId
head -100 src/control-plane/extensions/tickets/store.ts

Repository: hack-dance/hack-cli

Length of output: 3506


🏁 Script executed:

# Search for how event files are organized and partitioned
rg -n "month|partition|appendEvents" src/control-plane/extensions/tickets/ --type=ts -A 3

Repository: hack-dance/hack-cli

Length of output: 3076


🏁 Script executed:

# Check git operations handling
rg -n "appendEvents|git\." src/control-plane/extensions/tickets/ --type=ts -B 2 -A 5 | head -80

Repository: hack-dance/hack-cli

Length of output: 5218


🏁 Script executed:

# Get the full appendEvents implementation
sed -n '350,400p' src/control-plane/extensions/tickets/tickets-git-channel.ts

Repository: hack-dance/hack-cli

Length of output: 1567


🏁 Script executed:

# Look for git push and merge conflict handling
rg -n "git push|git merge|conflict|CONFLICT" src/control-plane/extensions/tickets/ --type=ts -B 2 -A 5

Repository: hack-dance/hack-cli

Length of output: 1685


🏁 Script executed:

# Check the full computeNextTicketId function
sed -n '242,260p' src/control-plane/extensions/tickets/store.ts

Repository: hack-dance/hack-cli

Length of output: 816


🏁 Script executed:

# Understand how readAllEvents works
sed -n '123,150p' src/control-plane/extensions/tickets/store.ts

Repository: hack-dance/hack-cli

Length of output: 967


🏁 Script executed:

# Check the conflict detection logic in pushWithRetry
sed -n '320,348p' src/control-plane/extensions/tickets/tickets-git-channel.ts

Repository: hack-dance/hack-cli

Length of output: 1240


🏁 Script executed:

# Look for where pendingEvents conflict check happens
sed -n '336,345p' src/control-plane/extensions/tickets/tickets-git-channel.ts -A 10

Repository: hack-dance/hack-cli

Length of output: 1885


🏁 Script executed:

# Get more context around the conflict detection
sed -n '320,350p' src/control-plane/extensions/tickets/tickets-git-channel.ts

Repository: hack-dance/hack-cli

Length of output: 1280


🏁 Script executed:

# Check if there's any validation of eventIds or ticketIds during merge/rebase
rg -n "eventId|ticketId" src/control-plane/extensions/tickets/tickets-git-channel.ts -B 2 -A 2

Repository: hack-dance/hack-cli

Length of output: 1346


🏁 Script executed:

# Look for any deduplication or conflict detection logic
rg -n "duplicate|collision|conflict|deduplicate" src/control-plane/extensions/tickets/ --type=ts

Repository: hack-dance/hack-cli

Length of output: 45


🏁 Script executed:

# Check what happens during checkoutHead - does it do a merge or rebase?
rg -n "checkoutHead" src/control-plane/extensions/tickets/tickets-git-channel.ts -B 5 -A 15

Repository: hack-dance/hack-cli

Length of output: 2485


🏁 Script executed:

# Check how eventIds are generated - are they UUIDs?
rg -n "eventId|randomUUID" src/control-plane/extensions/tickets/ --type=ts -B 2 -A 2

Repository: hack-dance/hack-cli

Length of output: 6323


🏁 Script executed:

# Look at buildEvent to see how eventId and ticketId are set
rg -n "buildEvent" src/control-plane/extensions/tickets/ --type=ts -B 5 -A 15

Repository: hack-dance/hack-cli

Length of output: 6379


Ticket ID collisions are possible in concurrent scenarios—implement distributed locking or centralized ID allocation.

Two concurrent ticket creation calls will compute the same ticketId from computeNextTicketId() (which simply reads the max existing ID and increments it with no synchronization). While the system prevents duplicate events via eventId deduplication (UUIDs), it does not validate ticketId uniqueness. When process A and process B both compute the next ID as "T-00006", both will create events with different eventIds but the same ticketId. On push failure, the retry-after-fetch mechanism (lines 333–347) re-applies pending events, but the ticketId collision persists because deduplication only checks eventId, not ticketId.

This risk is realistic if multiple processes or instances of the CLI create tickets concurrently. Implement distributed locking (e.g., via Git branch locks, Redis locks, or a dedicated service) or a centralized ID allocation mechanism to guarantee monotonic ticket IDs.

🤖 Prompt for AI Agents
In @src/control-plane/extensions/tickets/store.ts around lines 273 - 308,
createTicket currently computes ticketId via computeNextTicketId and can produce
collisions under concurrency; wrap the ID allocation+append in a distributed
lock (or call out to a centralized allocator) so only one process can reserve
the next ID at a time: acquire a lock keyed by the project/namespace before
calling computeNextTicketId, build the event and call git.appendEvents, then
release the lock (handle failures by releasing and retrying). Alternatively,
replace computeNextTicketId with a central allocator service you call from
createTicket. Ensure any retry-after-fetch logic still verifies ticketId
uniqueness (not just eventId) by checking existing tickets for the allocated
ticketId before returning success; reference createTicket, computeNextTicketId,
git.appendEvents and the retry/apply logic that currently deduplicates by
eventId.

Comment on lines +12 to +15
export function formatTicketId(n: number): string {
const padded = String(n).padStart(5, "0")
return `T-${padded}`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider adding input validation.

The function assumes n is a non-negative integer but doesn't validate this. Negative numbers would produce malformed IDs like "T--0005", and floats would include decimals.

✅ Proposed fix to add validation
 export function formatTicketId(n: number): string {
+  if (!Number.isInteger(n) || n < 0) {
+    throw new Error(`Invalid ticket number: ${n}`)
+  }
   const padded = String(n).padStart(5, "0")
   return `T-${padded}`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function formatTicketId(n: number): string {
const padded = String(n).padStart(5, "0")
return `T-${padded}`
}
export function formatTicketId(n: number): string {
if (!Number.isInteger(n) || n < 0) {
throw new Error(`Invalid ticket number: ${n}`)
}
const padded = String(n).padStart(5, "0")
return `T-${padded}`
}
🤖 Prompt for AI Agents
In @src/control-plane/extensions/tickets/util.ts around lines 12 - 15,
formatTicketId currently assumes n is a non-negative integer; add input
validation at the top of the function (formatTicketId) to ensure
Number.isInteger(n) and n >= 0 and throw a clear TypeError if not, so negatives,
floats, NaN or non-numbers are rejected and malformed IDs like "T--0005" or
"T-1.23" can't be produced; keep the existing padding logic after the guard.

Comment on lines +17 to +24
export function parseTicketNumber(ticketId: string): number | null {
const trimmed = ticketId.trim()
if (!trimmed.startsWith("T-")) return null
const rest = trimmed.slice(2)
const n = Number(rest)
if (!Number.isFinite(n)) return null
return Math.trunc(n)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add validation for non-negative numbers.

The function doesn't validate that the parsed number is non-negative. Input like "T--5" would parse to -5, which should be invalid for a ticket number.

✅ Proposed fix to reject negative numbers
 export function parseTicketNumber(ticketId: string): number | null {
   const trimmed = ticketId.trim()
   if (!trimmed.startsWith("T-")) return null
   const rest = trimmed.slice(2)
   const n = Number(rest)
-  if (!Number.isFinite(n)) return null
+  if (!Number.isFinite(n) || n < 0) return null
   return Math.trunc(n)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function parseTicketNumber(ticketId: string): number | null {
const trimmed = ticketId.trim()
if (!trimmed.startsWith("T-")) return null
const rest = trimmed.slice(2)
const n = Number(rest)
if (!Number.isFinite(n)) return null
return Math.trunc(n)
}
export function parseTicketNumber(ticketId: string): number | null {
const trimmed = ticketId.trim()
if (!trimmed.startsWith("T-")) return null
const rest = trimmed.slice(2)
const n = Number(rest)
if (!Number.isFinite(n) || n < 0) return null
return Math.trunc(n)
}
🤖 Prompt for AI Agents
In @src/control-plane/extensions/tickets/util.ts around lines 17 - 24, The
parseTicketNumber function currently allows negative values like "T--5"; update
parseTicketNumber to validate the parsed numeric value is non-negative before
returning: after converting rest to Number and confirming Number.isFinite(n),
check that n >= 0 (or Math.trunc(n) >= 0) and return null for negatives; keep
existing trimming, "T-" prefix check, finite check, and return Math.trunc(n)
only when the value is non-negative.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @CLAUDE.md:
- Line 44: Fix the typos in the policy sentence on line 44: change "sensure" to
"ensure" and "tpye" to "type" so the sentence reads correctly about leveraging
generics and smart types to ensure the best possible type inference across the
project.
🧹 Nitpick comments (2)
docs/extensions.md (2)

386-406: Minor formatting and wording refinements in gateway exposure section.

Line 390: "ad‑hoc" uses a special character; standard hyphen "ad-hoc" is preferred.

Line 400: "Zero Trust/VPN network" is slightly redundant; consider: "Move to a private Zero Trust network or VPN".

💄 Suggested refinements
- SSH tunnel (ad‑hoc): `ssh -L 7788:127.0.0.1:7788 <host>`
+ SSH tunnel (ad-hoc): `ssh -L 7788:127.0.0.1:7788 <host>`

- 2) Move to a private Zero Trust/VPN network once you want persistent access.
+ 2) Move to a private Zero Trust network or VPN once you want persistent access.

411-439: Add language specification to fenced code blocks.

Lines 411, 425, and 435 contain JSON examples in fenced code blocks without language specification. Adding json will enable proper syntax highlighting and improve markdown linting compliance.

💄 Proposed fix
  ## Supervisor streaming output (JSON)
  
  `hack x supervisor job-tail --json` and `job-attach --json` emit line-delimited JSON events:
  
- ```
+ ```json
  {"type":"start","jobId":"...","logsOffset":0,"eventsSeq":0}
  
  ...
  
- ```
+ ```json
  Client → server (JSON frames):
  
- ```
+ ```json
  {"type":"hello","cols":120,"rows":30}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 672cf6c and fd96b18.

📒 Files selected for processing (7)
  • AGENTS.md
  • CLAUDE.md
  • docs/README.md
  • docs/cli.md
  • docs/extensions.md
  • docs/guides/tickets.md
  • docs/supervisor.md
✅ Files skipped from review due to trivial changes (1)
  • docs/supervisor.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • AGENTS.md
  • docs/README.md
  • docs/guides/tickets.md
🧰 Additional context used
🪛 LanguageTool
CLAUDE.md

[grammar] ~44-~44: Ensure spelling is correct
Context: ... leveraging generics and smart types to sensure the best possible tpye inference across...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~44-~44: Ensure spelling is correct
Context: ...mart types to sensure the best possible tpye inference across the project. <!-- hac...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

docs/cli.md

[style] ~274-~274: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...e combined with --pretty. - --until cannot be combined with --follow. ### hack ...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

docs/extensions.md

[grammar] ~390-~390: Ensure spelling is correct
Context: ...xpose it through one of: - SSH tunnel (ad‑hoc): ssh -L 7788:127.0.0.1:7788 <host> -...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[style] ~400-~400: This phrase is redundant (‘N’ stands for ‘network’). Use simply “VPN”.
Context: ...access. 2) Move to a private Zero Trust/VPN network once you want persistent access. 3) Add...

(ACRONYM_TAUTOLOGY)

🪛 markdownlint-cli2 (0.18.1)
docs/cli.md

61-61: Table column count
Expected: 2; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


743-743: Table column count
Expected: 4; Actual: 7; Too many cells, extra data will be missing

(MD056, table-column-count)


765-765: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


783-783: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


855-855: Table column count
Expected: 4; Actual: 6; Too many cells, extra data will be missing

(MD056, table-column-count)


856-856: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


908-908: Table column count
Expected: 4; Actual: 7; Too many cells, extra data will be missing

(MD056, table-column-count)

docs/extensions.md

93-93: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


142-142: Table column count
Expected: 4; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)


411-411: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


425-425: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


435-435: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🔇 Additional comments (4)
CLAUDE.md (2)

46-71: Well-documented tickets workflow.

The new tickets section clearly explains commands, the recommended body template, and data location. This aligns well with the tickets extension described in the broader PR.


73-80: Clear session completion workflow.

The "Landing the Plane" section provides actionable steps for ending work sessions. This complements the tickets system and promotes good collaborative practices.

docs/cli.md (1)

1-50: Comprehensive and well-organized CLI reference.

The documentation is clearly structured with conventions, logical command groupings, and consistent formatting. The coverage appears thorough and should serve as a good reference for users.

docs/extensions.md (1)

1-100: Well-structured extension surface documentation.

The file provides clear definitions of the extension API, configuration, dispatch model, and built-in extensions. Type definitions are precise, and command references are comprehensive.

Comment thread CLAUDE.md
TypeScript (strict). Runtimes: Bun 1.3+, Node 23. Prettier: 2 spaces, no semicolons, double quotes, width 100. Import order: @ianvs/prettier-plugin-sort-imports + Tailwind plugin. ESLint (flat): any disallowed; unused vars warned (prefix \_ to ignore). Naming: React components PascalCase; files kebab-case (e.g., user-profile.ts); packages @repo/<name>. Comments: avoid writing inline comments everywhere, unless absolutely necessary for a todo or an important thing to take note of. Instead write tsdoc style block level comments at the method/class/function/route level. Focus on comments that provide value in regards to better type inference and clarity of usage. Using things like params/returns/etc..

Always default to useing named paramaters in functions eg myFunction({ ctx, other }) vs myFunction(ctx, other)
Never use any types and always default to leveraging generics and smart types to sensure the best possible tpye inference across the project.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix spelling errors in type inference policy line.

Line 44 contains typos: "sensure" → "ensure" and "tpye" → "type".

✏️ Proposed fix
- Never use any types and always default to leveraging generics and smart types to sensure the best possible tpye inference across the project.
+ Never use any types and always default to leveraging generics and smart types to ensure the best possible type inference across the project.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Never use any types and always default to leveraging generics and smart types to sensure the best possible tpye inference across the project.
Never use any types and always default to leveraging generics and smart types to ensure the best possible type inference across the project.
🧰 Tools
🪛 LanguageTool

[grammar] ~44-~44: Ensure spelling is correct
Context: ... leveraging generics and smart types to sensure the best possible tpye inference across...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~44-~44: Ensure spelling is correct
Context: ...mart types to sensure the best possible tpye inference across the project. <!-- hac...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
In @CLAUDE.md at line 44, Fix the typos in the policy sentence on line 44:
change "sensure" to "ensure" and "tpye" to "type" so the sentence reads
correctly about leveraging generics and smart types to ensure the best possible
type inference across the project.

- Add apps/macos/build/ to gitignore (Xcode build output)
- Add .zig-cache/, zig-cache/, zig-out/ to gitignore
- Untrack 2207 previously committed build artifacts
- Add ensureGitignoreEntry() helper to lib/fs.ts
- Auto-add .hack/.internal/ to .gitignore during hack init

The .hack/.internal directory contains machine-specific data like
cert paths and extra_hosts mappings that shouldn't be committed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

6-15: Fix mismatched closing tag for the ASCII-art block.

<pre> is opened but closed with </p>, which is invalid HTML and can break rendering.

🛠️ Proposed fix
-</p>
+</pre>
🤖 Fix all issues with AI agents
In
`@apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift`:
- Around line 304-334: Mark the C-interop raw structs as `@frozen` to guarantee
stable memory layout: update the declarations for GhosttyRenderColorRaw,
GhosttyRenderCellRaw, and GhosttyRenderSnapshotRaw by adding the `@frozen`
attribute before each struct keyword (i.e., change "struct
GhosttyRenderColorRaw", "struct GhosttyRenderCellRaw", and "struct
GhosttyRenderSnapshotRaw" to "@frozen struct ..."). This ensures the field
ordering and memory layout remain stable for dlsym/C interop.

In
`@apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/PtyProcess.swift`:
- Around line 26-44: The init currently ignores tcgetattr failures and always
passes a zeroed termios to openpty; change it to check tcgetattr(STDIN_FILENO,
&term) and only pass &term to openpty if tcgetattr returns 0, otherwise pass nil
to preserve system defaults; update the openpty call in PtyProcess.init to use a
conditional term pointer (e.g., termPtr) so openpty(&master, &slave, nil,
termPtr, &size) is used when tcgetattr fails.

In `@scripts/macos-ghostty-setup.ts`:
- Around line 51-53: The regex in the minVersionMatch extraction is
double-escaped (e.g., "\\s", "\\d", "\\.") so it never matches; update the
RegExp passed to String.match in the readFileSync(...) call (the expression
assigning minVersionMatch) to use proper regex escapes (single backslashes in a
regex literal) so it matches minimum_zig_version = "X.Y.Z" and captures the
version into the existing capture group; ensure you leave the surrounding call
to readFileSync, path.join(vendorDir, "build.zig.zon"), and the minVersionMatch
variable intact.

In `@src/commands/usage.ts`:
- Around line 701-706: The readPidFile function should guard against
IO/permission errors and return null instead of allowing exceptions to bubble;
wrap the Bun.file(opts.path), await file.exists() and await file.text() calls in
a try/catch and on any caught error return null, then keep the existing parsing
logic (Number.parseInt(text.trim(), 10) and Number.isFinite check) so
readPidFile always resolves to a number or null even when file access fails.
♻️ Duplicate comments (5)
src/commands/project.ts (1)

7-7: Version issue already flagged.

The @clack/prompts version 1.0.0-alpha.9 concern was previously raised. Ensure this dependency version is resolved.

docs/gateway-api.md (4)

103-107: Add a language identifier to this fenced URL block.

This was already flagged earlier; still applies.

🔎 Proposed fix
-```
+```text
 https://gateway.dimitri.computer
</details>

---

`146-151`: **Add a language identifier to this WS URL block.**

This was already flagged earlier; still applies.  

<details>
<summary>🔎 Proposed fix</summary>

```diff
-```
+```text
 wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>
</details>

---

`338-342`: **Insert a blank line before the response table.**

This was already flagged earlier; still applies.  

<details>
<summary>🔎 Proposed fix</summary>

```diff
 Response:
+
 | Field | Type | Description |

404-408: Insert a blank line before the response table.

This was already flagged earlier; still applies.

🔎 Proposed fix
 Response:
+
 | Field | Type | Description |
🟡 Minor comments (18)
CLAUDE.md-43-43 (1)

43-43: Fix spelling errors in the named parameters guideline.

Line 43 contains typos: "useing" → "using" and "paramaters" → "parameters".

✏️ Proposed fix
-Always default to useing named paramaters in functions eg myFunction({ ctx, other }) vs myFunction(ctx, other)
+Always default to using named parameters in functions eg myFunction({ ctx, other }) vs myFunction(ctx, other)
scripts/macos-ghostty-setup.ts-15-15 (1)

15-15: Handle missing zig command gracefully.

If Zig is not installed, this line throws an unhandled exception. Consider wrapping in try/catch to provide a user-friendly error message.

Suggested fix
-const zigVersion = (await $`zig version`.text()).trim()
+let zigVersion: string
+try {
+  zigVersion = (await $`zig version`.text()).trim()
+} catch {
+  console.error("Zig is not installed or not in PATH. Install Zig and retry.")
+  process.exit(1)
+}
scripts/macos-ghostty-setup.ts-72-74 (1)

72-74: Hardcoded version in error message.

The error message contains "mise install zig@0.15.2" but should use the dynamically parsed minVersion values for consistency.

Suggested fix
   console.error(
-    `Ghostty VT currently targets Zig ${minVersion.major}.${minVersion.minor}.x (min ${minVersion.major}.${minVersion.minor}.${minVersion.patch}). Install Zig ${minVersion.major}.${minVersion.minor}.${minVersion.patch} (mise: "mise install zig@0.15.2") and retry.`
+    `Ghostty VT currently targets Zig ${minVersion.major}.${minVersion.minor}.x (min ${minVersion.major}.${minVersion.minor}.${minVersion.patch}). Install Zig ${minVersion.major}.${minVersion.minor}.${minVersion.patch} (mise: "mise install zig@${minVersion.major}.${minVersion.minor}.${minVersion.patch}") and retry.`
   )
scripts/macos-ghostty-setup.ts-10-13 (1)

10-13: Guard against undefined HOME environment variable.

If HOME is not set, installDir resolves to /Library/Application Support/..., which is a system-wide path requiring elevated privileges and is likely unintended.

Suggested fix
+const homeDir = process.env.HOME
+if (!homeDir) {
+  console.error("HOME environment variable is not set.")
+  process.exit(1)
+}
 const installDir = path.join(
-  process.env.HOME ?? "",
+  homeDir,
   "Library/Application Support/Hack/ghostty/lib"
 )
src/commands/usage.ts-642-649 (1)

642-649: Fix the label for hack remote detection.
hack remote is currently reported as hack tui, which mislabels host usage rows.

✅ Proposed fix
-  if (normalized.includes(" hack remote")) return "hack tui"
+  if (normalized.includes(" hack remote")) return "hack remote"
src/commands/usage.ts-752-755 (1)

752-755: Treat empty docker stats output as a valid result, not an error.

Containers can stop between indexing and stats collection, causing Docker to return no samples. This is a transient state, not a failure condition. The code already handles empty samples gracefully in downstream functions, and even establishes this pattern by manually returning { ok: true, samples: [] } when containerIds.length === 0. Returning an error for empty samples only when they come from the Docker command creates an unnecessary inconsistency.

Proposed fix
-  if (samples.length === 0) {
-    return { ok: false, error: "docker stats returned no samples" }
-  }
-  return { ok: true, samples }
+  return { ok: true, samples }
AGENTS.md-76-79 (1)

76-79: Align workflow terminology with tickets.

Line 79 still says “issue status”; this now conflicts with the tickets wording in Line 77.

✏️ Proposed edit
-3. **Update issue status** - Close finished work, update in-progress items
+3. **Update ticket status** - Close finished work, update in-progress items
examples/basic/.hack/.internal/compose.override.yml-8-15 (1)

8-15: Avoid hard-coded absolute CA cert paths.

Line 9 hard-codes /Users/hack/..., which will break on other machines/CI. Prefer ${HOME} or a configurable env var.

🔧 Proposed fix
-      - /Users/hack/.hack/caddy/pki/caddy-local-authority.crt:/etc/hack/ca/caddy-local-authority.crt:ro
+      - ${HOME}/.hack/caddy/pki/caddy-local-authority.crt:/etc/hack/ca/caddy-local-authority.crt:ro
examples/next-app/.hack/.internal/compose.override.yml-1-71 (1)

1-71: De-duplicate CA config and avoid absolute user paths.

The repeated blocks are error‑prone, and the /Users/hack/... path won’t work for other devs/CI. Consider a YAML anchor + ${HOME}.

♻️ Suggested refactor pattern
+x-ca-config: &ca-config
+  dns:
+    - 172.30.0.2
+  extra_hosts:
+    examples-next-app.hack: 172.30.0.3
+  volumes:
+    - ${HOME}/.hack/caddy/pki/caddy-local-authority.crt:/etc/hack/ca/caddy-local-authority.crt:ro
+  environment:
+    SSL_CERT_FILE: /etc/hack/ca/caddy-local-authority.crt
+    SSL_CERT_DIR: /etc/hack/ca
+    NODE_EXTRA_CA_CERTS: /etc/hack/ca/caddy-local-authority.crt
+    REQUESTS_CA_BUNDLE: /etc/hack/ca/caddy-local-authority.crt
+    CURL_CA_BUNDLE: /etc/hack/ca/caddy-local-authority.crt
+    GIT_SSL_CAINFO: /etc/hack/ca/caddy-local-authority.crt
+
 services:
   db:
-    dns:
-      - 172.30.0.2
-    extra_hosts:
-      examples-next-app.hack: 172.30.0.3
-    volumes:
-      - /Users/hack/.hack/caddy/pki/caddy-local-authority.crt:/etc/hack/ca/caddy-local-authority.crt:ro
-    environment:
-      SSL_CERT_FILE: /etc/hack/ca/caddy-local-authority.crt
-      SSL_CERT_DIR: /etc/hack/ca
-      NODE_EXTRA_CA_CERTS: /etc/hack/ca/caddy-local-authority.crt
-      REQUESTS_CA_BUNDLE: /etc/hack/ca/caddy-local-authority.crt
-      CURL_CA_BUNDLE: /etc/hack/ca/caddy-local-authority.crt
-      GIT_SSL_CAINFO: /etc/hack/ca/caddy-local-authority.crt
+    <<: *ca-config
docs/gateway-api.md-111-116 (1)

111-116: Add a period to “etc.”

Minor grammar fix in the Tailscale section.

✏️ Proposed fix
-Best for SSH from iOS clients (Terminus, Blink, etc):
+Best for SSH from iOS clients (Terminus, Blink, etc.):
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift-260-275 (1)

260-275: Normalize devHost before building the URL.

The context menu unconditionally prefixes https://, which breaks when devHost already includes a scheme. This produces invalid URLs for Open/Copy.

🛠 Proposed fix
-    if let devHost = project.devHost, let url = URL(string: "https://\(devHost)") {
-      Divider()
-
-      Button {
-        NSWorkspace.shared.open(url)
-      } label: {
-        Label("Open in Browser", systemImage: "safari")
-      }
-
-      Button {
-        NSPasteboard.general.clearContents()
-        NSPasteboard.general.setString(url.absoluteString, forType: .string)
-      } label: {
-        Label("Copy URL", systemImage: "doc.on.doc")
-      }
-    }
+    if let devHost = project.devHost, !devHost.isEmpty {
+      let urlString = devHost.contains("://") ? devHost : "https://\(devHost)"
+      if let url = URL(string: urlString) {
+        Divider()
+
+        Button {
+          NSWorkspace.shared.open(url)
+        } label: {
+          Label("Open in Browser", systemImage: "safari")
+        }
+
+        Button {
+          NSPasteboard.general.clearContents()
+          NSPasteboard.general.setString(url.absoluteString, forType: .string)
+        } label: {
+          Label("Copy URL", systemImage: "doc.on.doc")
+        }
+      }
+    }
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift-48-57 (1)

48-57: Force Overview for non-runtime projects.

When project.isRuntimeConfigured is false, tabContent can still render Logs/Shell if a prior selection persists, which hides the runtime-not-configured guidance and can start unavailable sessions. Consider defaulting to Overview in that case.

🛠 Proposed fix
 `@ViewBuilder`
 private var tabContent: some View {
-  switch model.selectedProjectTab {
-  case .overview:
-    overviewContent
-  case .logs:
-    LogsView(project: project, embedded: true)
-  case .shell:
-    ShellView(project: project, embedded: true)
-  }
+  if !project.isRuntimeConfigured {
+    overviewContent
+  } else {
+    switch model.selectedProjectTab {
+    case .overview:
+      overviewContent
+    case .logs:
+      LogsView(project: project, embedded: true)
+    case .shell:
+      ShellView(project: project, embedded: true)
+    }
+  }
 }
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusBadge.swift-43-87 (1)

43-87: Remove redundant optional initialization.
SwiftLint flags = nil as redundant for optional types. The same pattern also appears in RuntimeStatusDot at line 129.

🧹 Proposed fixes
-  var runtimeHealthy: Bool? = nil
+  var runtimeHealthy: Bool?
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusBadge.swift-127-171 (1)

127-171: Remove redundant optional initialization.

In Swift, optional variables implicitly initialize to nil, so explicit = nil is unnecessary. SwiftLint flags this redundancy:

-  var runtimeHealthy: Bool? = nil
+  var runtimeHealthy: Bool?
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift-69-81 (1)

69-81: Avoid setting isStarted before availability is confirmed.

If the runtime is unavailable, isStarted stays true and blocks retries. Move the assignment after the availability check (or reset on failure).

🛠️ Proposed fix
-    isStarted = true
-    guard isAvailable else { return }
+    guard isAvailable else { return }
+    isStarted = true
docs/rfcs/0001-multi-node-hack-cluster.md-103-115 (1)

103-115: Add language identifiers to fenced code blocks.

This addresses MD040 and improves readability.

🛠️ Proposed fix
-```
+```text
 controlPlane.nodeId = "node-123"

```diff
-```
+```text
 session_id = "project:repo:agent:codex"
</details>

</blockquote></details>
<details>
<summary>apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift-261-271 (1)</summary><blockquote>

`261-271`: **Memory leak if `htmlString` returns a valid pointer with length 0.**

Lines 264-266: If `ptr` is non-nil but `length == 0`, the function returns an empty string but never calls `freeString` to release the pointer. The same issue exists in `plainString` (lines 252-254).



<details>
<summary>Fix potential memory leak</summary>

```diff
   public func htmlString() -> String? {
     guard let htmlString = functions.htmlString else { return nil }
     var length = 0
-    guard let ptr = htmlString(handle, &length), length > 0 else {
-      return ""
+    guard let ptr = htmlString(handle, &length) else {
+      return nil
     }
+    defer { functions.freeString(ptr, length) }
+    guard length > 0 else { return "" }
     let buffer = UnsafeBufferPointer(start: ptr, count: length)
     let text = String(decoding: buffer, as: UTF8.self)
-    functions.freeString(ptr, length)
     return text
   }

Apply the same pattern to plainString.

apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift-80-90 (1)

80-90: Task is not cancelled on deinitialization.

The refreshTask runs indefinitely but is never cancelled when DashboardModel is deallocated. This could cause the task to continue running (with a nil weak self) or prevent deallocation if there's a strong reference cycle.

Add deinit to cancel the refresh task
   public init(client: HackCLIClient) {
     self.client = client
   }

+  deinit {
+    refreshTask?.cancel()
+    statusClearTask?.cancel()
+  }
+
   public var selectedProject: ProjectSummary? {
🧹 Nitpick comments (28)
.gitignore (1)

64-64: Remove duplicate dist ignore entry.

dist is already ignored at Line 6; the second entry is redundant. Optional cleanup to reduce noise.

apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift (1)

37-43: Consider using path-aware joining for robustness.

The current string concatenation works in most cases, but PATH entries with trailing slashes would produce paths like /usr/bin//hack. While Unix handles this gracefully, using URL or NSString path APIs would be more robust.

♻️ Optional: Use URL-based path construction
     guard let pathValue = env["PATH"] else { return nil }
     for entry in pathValue.split(separator: ":") {
-      let candidate = String(entry) + "/hack"
+      let candidate = URL(fileURLWithPath: String(entry)).appendingPathComponent("hack").path
       if fileManager.isExecutableFile(atPath: candidate) {
         return candidate
       }
     }
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift (1)

7-30: Platform-conditional rendering is well-implemented.

The glassEffect API was introduced in iOS 26 and macOS Tahoe as part of the "Liquid Glass" design system. The availability check and fallback implementation are appropriate.

Consider extracting the shared modifiers (font, padding, foregroundStyle) to reduce duplication between branches.

♻️ Optional: Extract shared modifiers
 struct BadgePill: View {
   let label: String
   let tint: Color

   var body: some View {
+    label
+      .font(.caption2.weight(.semibold))
+      .padding(.horizontal, 8)
+      .padding(.vertical, 4)
+      .foregroundStyle(tint)
+      .modifier(BadgeBackground(tint: tint))
+  }
+}
+
+private struct BadgeBackground: ViewModifier {
+  let tint: Color
+
+  func body(content: Content) -> some View {
     if `#available`(macOS 26, *) {
-      Text(label)
-        .font(.caption2.weight(.semibold))
-        .padding(.horizontal, 8)
-        .padding(.vertical, 4)
-        .foregroundStyle(tint)
+      content
         .glassEffect(.regular.tint(tint.opacity(0.15)))
     } else {
-      Text(label)
-        .font(.caption2.weight(.semibold))
-        .padding(.horizontal, 8)
-        .padding(.vertical, 4)
-        .foregroundStyle(tint)
+      content
         .background(
           Capsule(style: .continuous)
             .fill(.thinMaterial)
             .overlay(
               Capsule(style: .continuous)
                 .stroke(tint.opacity(0.35), lineWidth: 1)
             )
         )
     }
   }
 }
CLAUDE.md (1)

77-78: Consider portability of hardcoded vault paths.

The Obsidian vault and project folder paths are hardcoded to specific absolute locations that may not be consistent across team members' environments. Consider documenting these as examples or using environment variables/configuration.

apps/macos/Config/Debug.xcconfig (1)

3-3: Preserve inherited compilation conditions.
Setting this directly can drop other flags defined by the project or dependencies. Prefer inheriting and appending DEBUG.

♻️ Proposed fix
-SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
+SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG
apps/macos/Config/Release.xcconfig (1)

3-3: Preserve inherited compilation conditions.
Setting this directly can drop other flags defined by the project or dependencies. Prefer inheriting and appending RELEASE.

♻️ Proposed fix
-SWIFT_ACTIVE_COMPILATION_CONDITIONS = RELEASE
+SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) RELEASE
scripts/macos-ghostty-setup.ts (1)

30-42: isAtLeast is defined but unused.

This utility function is never called. The version check on lines 67-70 reimplements the comparison inline (with slightly different semantics). Either use isAtLeast or remove it.

apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift (1)

29-37: Add rounded clipping on macOS 26 paths for visual consistency.

The macOS 26 branches omit the rounded shape used in legacy branches, so the button silhouette can change between OS versions.

♻️ Suggested tweak
       configuration.label
         .padding(.horizontal, 12)
         .padding(.vertical, 6)
         .foregroundStyle(.primary)
+        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
         .glassEffect(.regular)
         .opacity(configuration.isPressed ? 0.8 : 1.0)
       configuration.label
         .padding(.horizontal, 12)
         .padding(.vertical, 6)
         .foregroundStyle(.red)
+        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
         .glassEffect(.regular.tint(Color.red.opacity(0.15)))
         .opacity(configuration.isPressed ? 0.8 : 1.0)

Also applies to: 53-61

apps/macos/docs/ghostty-vt.md (2)

70-72: Add a language identifier to the fenced code block.

The code block representing the rendering pipeline lacks a language specifier. Based on static analysis hints (MD040), consider adding a language identifier for consistency.

📝 Suggested fix
-```
+```text
 PTY bytes -> Ghostty VT -> HTML snapshot -> NSTextView
-```
+```

85-134: Consider using proper headings instead of bold emphasis.

The checklist subsections use bold text (**Bridge (Zig → C ABI)**, etc.) rather than Markdown headings. While readable, converting these to #### headings would improve document structure and navigation (flagged by MD036).

This is a minor stylistic preference and can be addressed later if desired.

apps/macos/Experiments/GhosttyVTBridge/src/bridge.zig (2)

75-86: Consider returning error status for resize failures.

Lines 79 and 85 silently discard errors. While acceptable for feed (VT streams often encounter unparseable sequences), resize failures (e.g., allocation failure) could leave the terminal in an inconsistent state.

Consider returning a boolean success indicator for hack_ghostty_vt_resize to allow callers to detect failures.

♻️ Suggested change
-export fn hack_ghostty_vt_resize(handle: ?*TerminalHandle, cols: u32, rows: u32) void {
+export fn hack_ghostty_vt_resize(handle: ?*TerminalHandle, cols: u32, rows: u32) bool {
-    if (handle == null) return;
+    if (handle == null) return false;
     const cols_u16 = toCellCount(cols) orelse return;
     const rows_u16 = toCellCount(rows) orelse return;
-    handle.?.terminal.resize(handle.?.alloc, cols_u16, rows_u16) catch {};
+    handle.?.terminal.resize(handle.?.alloc, cols_u16, rows_u16) catch return false;
+    return true;
 }

234-239: Consider storing the allocator in the snapshot for consistent deallocation.

The snapshot is allocated using handle.?.alloc (line 128) but freed using std.heap.c_allocator (line 236). Currently both are c_allocator, but this creates a hidden coupling. If TerminalHandle.alloc ever changes, this would cause undefined behavior.

Consider either storing the allocator reference in the snapshot or adding a comment documenting this constraint.

apps/macos/README.md (1)

67-69: Add a language specifier to the fenced code block.

The architecture diagram code block lacks a language specifier. Consider using text or plaintext to satisfy markdown linters and improve accessibility.

-```
+```text
 PTY bytes -> Ghostty VT (zig) -> formatter (HTML/plain) -> AppKit NSTextView

</blockquote></details>
<details>
<summary>apps/macos/Package.swift (1)</summary><blockquote>

`1-38`: **Package manifest is well-structured.**

The dependency graph is clean and acyclic. The target paths are explicitly specified, which is good for non-standard layouts.

Consider adding test targets for `HackCLIService`, `GhosttyTerminal`, and `DashboardFeature` to improve test coverage as the project matures.

</blockquote></details>
<details>
<summary>apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift (1)</summary><blockquote>

`59-70`: **Consider moving icon mapping to the model layer.**

The `iconName` computed property uses string literals to map exposure IDs to SF Symbol names. This logic could be encapsulated in the `GatewayExposure` model itself (similar to `statusColor`) to improve cohesion and reduce duplication if icons are needed elsewhere.



<details>
<summary>♻️ Potential refactor in GatewayExposure model</summary>

```swift
// In GatewayExposure extension
var iconName: String {
    switch id {
    case "lan": return "wifi"
    case "tailscale": return "link"
    case "cloudflare": return "cloud"
    default: return "network"
    }
}

Then simplify the view:

-  private var iconName: String {
-    switch exposure.id {
-    case "lan":
-      return "wifi"
-    case "tailscale":
-      return "link"
-    case "cloudflare":
-      return "cloud"
-    default:
-      return "network"
-    }
-  }
+  // Use exposure.iconName directly in the Label
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusPill.swift (1)

3-27: Consider explicit access modifiers for cross-module usage.

StatusTone and StatusPill default to internal access. If these are intended for use outside this module (as suggested by the AI summary mentioning "shared UI element"), consider adding public access modifiers.

♻️ Suggested change
-enum StatusTone {
+public enum StatusTone {
   case good
   case warn
   case neutral
 }

-struct StatusPill: View {
-  let text: String
-  let tone: StatusTone
+public struct StatusPill: View {
+  public let text: String
+  public let tone: StatusTone
+
+  public init(text: String, tone: StatusTone) {
+    self.text = text
+    self.tone = tone
+  }
src/commands/daemon.ts (1)

199-264: LGTM with minor suggestion.

The enhanced status handler properly combines process state with API reachability for more accurate diagnostics. Consider using strict equality (===) instead of loose equality (==) for the string comparisons on lines 247, 250, and 255 for consistency with TypeScript best practices.

src/commands/project.ts (1)

861-900: Consider extracting shared logic with resolveCoreDnsServer.

This function is nearly identical to resolveCoreDnsServer (lines 820-859), differing only in the service name and environment variable. Consider extracting a shared helper to reduce duplication.

♻️ Suggested refactor
async function resolveGlobalComposeServiceIp(opts: {
  readonly envVar: string
  readonly serviceName: string
}): Promise<string | null> {
  const env = (process.env[opts.envVar] ?? "").trim()
  if (env.length > 0) return env

  const home = process.env.HOME
  if (!home) return null

  const composePath = resolve(
    home,
    GLOBAL_HACK_DIR_NAME,
    GLOBAL_CADDY_DIR_NAME,
    GLOBAL_CADDY_COMPOSE_FILENAME
  )
  if (!(await pathExists(composePath))) return null

  const ps = await exec(["docker", "compose", "-f", composePath, "ps", "-q", opts.serviceName], {
    cwd: dirname(composePath),
    stdin: "ignore"
  })
  const id = ps.exitCode === 0 ? ps.stdout.trim() : ""
  if (!id) return null

  const inspect = await exec(["docker", "inspect", "--format", "{{json .NetworkSettings.Networks}}", id], {
    stdin: "ignore"
  })
  if (inspect.exitCode !== 0) return null

  let parsed: unknown
  try {
    parsed = JSON.parse(inspect.stdout)
  } catch {
    return null
  }
  if (!isRecord(parsed)) return null

  const network = parsed[DEFAULT_INGRESS_NETWORK]
  if (!isRecord(network)) return null
  const ip = network["IPAddress"]
  return typeof ip === "string" && ip.length > 0 ? ip : null
}

// Usage:
const resolveCoreDnsServer = () => resolveGlobalComposeServiceIp({ envVar: "HACK_COREDNS_IP", serviceName: "coredns" })
const resolveCaddyServer = () => resolveGlobalComposeServiceIp({ envVar: "HACK_CADDY_IP", serviceName: "caddy" })
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DetailRows.swift (1)

3-24: Prefer a stable identifier from row data rather than UUID.

When rows array rebuilds (which is common in reactive views), creating fresh UUID instances causes ForEach to treat items as new, breaking state, focus, and animations. Using a stable id—such as the label or an explicit id provided by the caller—improves identity consistency across renders and aligns with SwiftUI best practices.

♻️ Optional refactor
-struct DetailRowItem: Identifiable {
-  let id = UUID()
-  let label: String
-  let value: String
-}
+struct DetailRowItem: Identifiable {
+  let id: String
+  let label: String
+  let value: String
+
+  init(id: String? = nil, label: String, value: String) {
+    self.id = id ?? label
+    self.label = label
+    self.value = value
+  }
+}
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalTextView.swift (5)

26-69: Font config parsing could silently ignore malformed values.

The config parser handles missing files gracefully but doesn't log or report when individual config values fail to parse (e.g., invalid font-size). This is acceptable for a user-facing config, but consider logging parse failures during development for easier debugging.

Additionally, lineHeightPadding is hardcoded to 2 when loading from config but 0 in the default config - this inconsistency may cause unexpected behavior if the user has a config file.

Consider making lineHeightPadding consistent
     return TerminalFontConfig(
       fontFamily: fontFamily,
       fontSize: fontSize,
       fontThicken: fontThicken,
-      lineHeightPadding: 2
+      lineHeightPadding: TerminalFontConfig.default.lineHeightPadding
     )

220-259: Cell size calculation is thorough but computationally intensive.

The glyph measurement approach using CTFont is correct. However, updateSize is called from onLayout which can fire frequently during window resizing. The font metrics calculation (lines 225-247) is relatively expensive.

Consider caching the computed cellWidth and lineHeight since they only depend on baseFont which doesn't change after initialization.

Cache computed cell metrics
   `@MainActor`
   final class Coordinator {
     weak var session: GhosttyTerminalSession?
     weak var renderView: TerminalRenderView?
     var lastRenderVersion: Int = -1
     private var lastCols: Int = 0
     private var lastRows: Int = 0
     private let fontConfig = TerminalFontConfig.loadFromGhosttyConfig()
     private lazy var baseFont: NSFont = fontConfig.resolveFont()
+    private lazy var cachedCellSize: CGSize = computeCellSize()
+    private lazy var cachedBaselineOffset: CGFloat = computeBaselineOffset()
+
+    private func computeCellSize() -> CGSize {
+      // Move cell size calculation here, called once
+      let ctFont = baseFont as CTFont
+      // ... existing calculation logic ...
+      return CGSize(width: cellWidth, height: lineHeight)
+    }

261-267: Potential redundant main queue dispatch.

ensureFocus dispatches to main queue, but the Coordinator is already @MainActor. The dispatch is likely to break a potential update cycle, but this pattern can cause focus to be set one runloop iteration late.


271-308: TerminalRenderView initialization loads font config twice.

Line 279 calls TerminalFontConfig.loadFromGhosttyConfig().resolveFont() for the default font property, but the Coordinator also loads it at line 142-143. This results in parsing the config file twice and potentially resolving different fonts if the file changes between calls.

Use a simpler default that will be overwritten
-  var font: NSFont = TerminalFontConfig.loadFromGhosttyConfig().resolveFont() {
+  var font: NSFont = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) {
     didSet {
       fontCache.removeAll()
       needsDisplay = true
     }
   }

626-772: Box-drawing lookup table is comprehensive and correctly indexed.

The 128-entry lookup table (0x2500-0x257F) properly maps each box-drawing character to its line segments. The implementation handles corners, T-junctions, crosses, double lines, and arcs correctly.

Minor note: The array is recomputed on every access since it's a computed property. For a fixed lookup table, consider making it a static constant.

Make lookup table static
-  /// Lookup table for box-drawing character line segments
-  private var boxDrawingLines: [(left: Bool, right: Bool, up: Bool, down: Bool)] {
+  /// Lookup table for box-drawing character line segments
+  private static let boxDrawingLines: [(left: Bool, right: Bool, up: Bool, down: Bool)] = [
     [
       // 2500-250F: Basic horizontal/vertical lines
       ...
     ]
-  }
+  ]
apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift (3)

33-51: Redundant nil initializations can be removed.

As flagged by SwiftLint, optional properties don't need explicit = nil initialization. While this is stylistic, removing them reduces noise.

Remove redundant nil initializations
-  public private(set) var daemonStatus: DaemonStatus? = nil
-  public private(set) var globalStatus: GlobalStatusResponse? = nil
-  public private(set) var runtimeOk: Bool? = nil
-  public private(set) var runtimeError: String? = nil
-  public private(set) var runtimeCheckedAt: String? = nil
-  public private(set) var runtimeLastOkAt: String? = nil
-  public private(set) var runtimeResetAt: String? = nil
-  public private(set) var runtimeResetCount: Int? = nil
-  public private(set) var lastUpdated: Date? = nil
+  public private(set) var daemonStatus: DaemonStatus?
+  public private(set) var globalStatus: GlobalStatusResponse?
+  public private(set) var runtimeOk: Bool?
+  public private(set) var runtimeError: String?
+  public private(set) var runtimeCheckedAt: String?
+  public private(set) var runtimeLastOkAt: String?
+  public private(set) var runtimeResetAt: String?
+  public private(set) var runtimeResetCount: Int?
+  public private(set) var lastUpdated: Date?
   public var selectedItem: SidebarItem? = .runtime
   public var selectedProjectTab: ProjectTab = .overview
-  public var errorMessage: String? = nil
-  public var statusMessage: String? = nil
+  public var errorMessage: String?
+  public var statusMessage: String?
   public var isRefreshing = false

   private let client: HackCLIClient
-  private var refreshTask: Task<Void, Never>? = nil
-  private var statusClearTask: Task<Void, Never>? = nil
+  private var refreshTask: Task<Void, Never>?
+  private var statusClearTask: Task<Void, Never>?

92-108: Concurrent fetch pattern is good but error aggregation could be improved.

Using async let for parallel fetches is efficient. However, the error messages are joined with newlines which may not render well in UI. Consider storing errors as an array or using a more structured error presentation.


208-227: Status clear task has a potential race condition.

If runAction is called twice in quick succession, the first statusClearTask is cancelled but the second one will clear the status message after 2 seconds, even if another action started. This is minor since the UX impact is just a prematurely cleared status.

The current approach is acceptable for this use case, but for more robust status handling, consider tracking the action that set the status.

apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift (1)

155-172: Library path resolution is reasonable but could be more discoverable.

The code checks HACK_GHOSTTY_VT_LIB env var first, then falls back to a hardcoded path. Consider documenting the expected library location or providing a better error message when the library is not found.

Comment on lines +304 to +334
struct GhosttyRenderColorRaw {
var r: UInt8
var g: UInt8
var b: UInt8
var a: UInt8
}

struct GhosttyRenderCellRaw {
var codepoint: UInt32
var fg: GhosttyRenderColorRaw
var bg: GhosttyRenderColorRaw
var wide: UInt8
var flags: UInt16
var _pad: UInt8
}

struct GhosttyRenderSnapshotRaw {
var rows: UInt16
var cols: UInt16
var cursor_x: UInt16
var cursor_y: UInt16
var cursor_visible: UInt8
var cursor_style: UInt8
var cursor_wide_tail: UInt8
var _pad0: UInt8
var default_fg: GhosttyRenderColorRaw
var default_bg: GhosttyRenderColorRaw
var cursor_color: GhosttyRenderColorRaw
var cell_count: Int
var cells: UnsafeMutablePointer<GhosttyRenderCellRaw>
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f \( -name "*.h" -o -name "*.hpp" -o -name "*.c" -o -name "*.cpp" \) | xargs rg -l 'GhosttyRenderCellRaw|GhosttyRenderSnapshotRaw|GhosttyRenderColorRaw' 2>/dev/null | head -20

Repository: hack-dance/hack

Length of output: 149


🏁 Script executed:

# Also check for any C files that might define these structs
rg 'struct.*GhosttyRender' --type c --type h -B2 -A10 2>/dev/null

Repository: hack-dance/hack

Length of output: 41


🏁 Script executed:

# Look for bridging headers or C imports in the Swift file
cat apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift | head -100

Repository: hack-dance/hack

Length of output: 2365


🏁 Script executed:

sed -n '304,334p' apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift

Repository: hack-dance/hack

Length of output: 752


🏁 Script executed:

# Check for module maps or C bridging headers
find . -name "*.modulemap" -o -name "*bridging*" -o -name "module.modulemap" | head -20

Repository: hack-dance/hack

Length of output: 41


🏁 Script executed:

# Look for Ghostty as external dependency in package files
find . -name "Package.swift" -o -name "Package.resolved" | xargs cat 2>/dev/null | grep -i ghostty -A5 -B5 | head -50

Repository: hack-dance/hack

Length of output: 1040


🏁 Script executed:

# Search for any imports or references to C modules at the top of the file
head -50 apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift | grep -E "import|@_private|@_raw"

Repository: hack-dance/hack

Length of output: 90


🏁 Script executed:

# Check context around lines 304-334 for `@frozen` attributes
sed -n '300,340p' apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift

Repository: hack-dance/hack

Length of output: 917


🏁 Script executed:

# Search for Ghostty C library references or linkage
rg -i "ghostty" --type swift . | grep -i "import\|linkage\|external\|c" | head -20

Repository: hack-dance/hack

Length of output: 2618


🏁 Script executed:

# Look for any memory safety or size validation code
rg "MemoryLayout|sizeof|withUnsafeBytes|withMemoryRebound" apps/macos/Packages/Services/GhosttyTerminal/ -B2 -A2 | head -40

Repository: hack-dance/hack

Length of output: 680


🏁 Script executed:

# Check the full GhosttyTerminal.swift file size to understand scope
wc -l apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift

Repository: hack-dance/hack

Length of output: 153


🏁 Script executed:

# Look for any MemoryLayout checks or size assertions in the file
rg "MemoryLayout|size|assert" apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift

Repository: hack-dance/hack

Length of output: 393


🏁 Script executed:

# Search for any comments or documentation about C struct alignment
grep -n "@frozen\|C layout\|memory layout\|interop\|compatible" apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift

Repository: hack-dance/hack

Length of output: 101


🏁 Script executed:

# Check if there's a bridging or C module declaration
find apps/macos/Packages/Services/GhosttyTerminal -name "*.modulemap" -o -name "*bridging*" -o -name "*.h"

Repository: hack-dance/hack

Length of output: 41


Raw structs used in C interop must be marked @frozen to guarantee memory layout stability.

The structs GhosttyRenderColorRaw, GhosttyRenderCellRaw, and GhosttyRenderSnapshotRaw are used to interface with C functions loaded via dlsym. Without the @frozen attribute, Swift reserves the right to reorder fields or change memory layout in future compiler versions, breaking C interop compatibility. While the explicit _pad fields show layout awareness, only @frozen provides the guarantee required for safe C interoperability.

Add @frozen to all three raw structs:

  • @frozen struct GhosttyRenderColorRaw
  • @frozen struct GhosttyRenderCellRaw
  • @frozen struct GhosttyRenderSnapshotRaw
🤖 Prompt for AI Agents
In
`@apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift`
around lines 304 - 334, Mark the C-interop raw structs as `@frozen` to guarantee
stable memory layout: update the declarations for GhosttyRenderColorRaw,
GhosttyRenderCellRaw, and GhosttyRenderSnapshotRaw by adding the `@frozen`
attribute before each struct keyword (i.e., change "struct
GhosttyRenderColorRaw", "struct GhosttyRenderCellRaw", and "struct
GhosttyRenderSnapshotRaw" to "@frozen struct ..."). This ensures the field
ordering and memory layout remain stable for dlsym/C interop.

Comment on lines +26 to +44
public init(
executableURL: URL,
arguments: [String],
environment: [String: String],
cols: Int,
rows: Int,
workingDirectory: URL? = nil
) throws {
var master: Int32 = 0
var slave: Int32 = 0
var term = termios()
_ = tcgetattr(STDIN_FILENO, &term)
var size = winsize()
size.ws_col = UInt16(clamping: cols)
size.ws_row = UInt16(clamping: rows)

if openpty(&master, &slave, nil, &term, &size) != 0 {
throw PtyProcessError.openFailed(errno)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let me check the file structure and context around this code
cd apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal || true
head -60 PtyProcess.swift | cat -n

Repository: hack-dance/hack

Length of output: 2097


🏁 Script executed:

# Search for other uses of tcgetattr or openpty in the codebase
rg "tcgetattr|openpty" --type swift -B 2 -A 2

Repository: hack-dance/hack

Length of output: 1854


🏁 Script executed:

# Check if there are any imports or utility functions related to PTY handling
rg "import.*Darwin|import.*Glibc|tcgetattr|openpty|termios" --type swift

Repository: hack-dance/hack

Length of output: 769


🏁 Script executed:

# Let me also search for any tests or documentation that might explain the intended behavior
find . -type f \( -name "*.swift" -o -name "*.md" \) -exec grep -l "tcgetattr\|openpty" {} \;

Repository: hack-dance/hack

Length of output: 146


🌐 Web query:

openpty termios NULL parameter behavior when tcgetattr fails

💡 Result:

Short answer: If you pass term == NULL to openpty(), openpty() does not try to read or copy the slave's termios — it simply skips terminal-attribute setup. If you pass a non-NULL term pointer, openpty() will apply those settings with tcsetattr() (and will fail if the tcsetattr call fails). If you call tcgetattr() yourself and it fails, that failure is independent (tcgetattr returns -1 and sets errno, e.g. ENOTTY/EBADF); openpty() itself won't call tcgetattr when term == NULL. [1][2]

Sources:

  • openpty(3) man page: termp NULL means terminal parameters are ignored. [1]
  • glibc support_openpty implementation: only calls tcsetattr(inner, …, termp) when termp is non-NULL (no tcgetattr call when termp is NULL). [2]

🌐 Web query:

BSD openpty() passing nil termios vs zeroed termios behavior

💡 Result:

Short answer: different behavior. If you pass NULL to openpty(), the slave keeps the system/kernel default terminal attributes. If you pass a zeroed struct termios (all fields 0) and openpty() applies it, you are explicitly setting the slave's termios to those zero values — which will clear control/local flags and set baud to 0 (B0), i.e. "hang up" / drop modem control, disable receiver bits (no CREAD), etc. In practice that will make the slave behave very differently (often unusable) compared with leaving termios alone. [1][2]

Sources:

  • openpty(3) — termp parameter: if non-NULL, its termios are applied to the slave. [1]
  • termios / baud-rate docs — zero baud (B0) means “hang up”; c_cflag and c_lflag bits control receiver, parity, HUPCL, CLOCAL, etc. Clearing them has the effects described above. [2]

Handle tcgetattr failure before calling openpty.

When STDIN is not a TTY (common in GUI applications), tcgetattr fails but the result is ignored, leaving term as a zeroed termios struct. Passing a zeroed termios to openpty sets the slave's terminal attributes to all-zero values: baud rate becomes B0 (hang up), receiver is disabled, and control flags are cleared, rendering the PTY unusable. Pass nil to openpty instead when tcgetattr fails to preserve system defaults.

Proposed fix
-    var term = termios()
-    _ = tcgetattr(STDIN_FILENO, &term)
+    var term = termios()
+    let hasTerm = tcgetattr(STDIN_FILENO, &term) == 0
     var size = winsize()
     size.ws_col = UInt16(clamping: cols)
     size.ws_row = UInt16(clamping: rows)
 
-    if openpty(&master, &slave, nil, &term, &size) != 0 {
+    let openResult = hasTerm
+      ? openpty(&master, &slave, nil, &term, &size)
+      : openpty(&master, &slave, nil, nil, &size)
+    if openResult != 0 {
       throw PtyProcessError.openFailed(errno)
     }
🤖 Prompt for AI Agents
In
`@apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/PtyProcess.swift`
around lines 26 - 44, The init currently ignores tcgetattr failures and always
passes a zeroed termios to openpty; change it to check tcgetattr(STDIN_FILENO,
&term) and only pass &term to openpty if tcgetattr returns 0, otherwise pass nil
to preserve system defaults; update the openpty call in PtyProcess.init to use a
conditional term pointer (e.g., termPtr) so openpty(&master, &slave, nil,
termPtr, &size) is used when tcgetattr fails.

Comment on lines +51 to +53
const minVersionMatch = readFileSync(path.join(vendorDir, "build.zig.zon"), "utf8").match(
/minimum_zig_version\\s*=\\s*\"(\\d+\\.\\d+\\.\\d+)\"/
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Regex metacharacters are double-escaped, preventing matches.

In JavaScript regex literals, \\s matches a literal backslash followed by 's', not whitespace. The same issue affects \\d and \\.. This regex will never match the expected minimum_zig_version = "X.Y.Z" pattern, causing the script to always fall back to the hardcoded default.

Proposed fix
 const minVersionMatch = readFileSync(path.join(vendorDir, "build.zig.zon"), "utf8").match(
-  /minimum_zig_version\\s*=\\s*\"(\\d+\\.\\d+\\.\\d+)\"/
+  /minimum_zig_version\s*=\s*"(\d+\.\d+\.\d+)"/
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const minVersionMatch = readFileSync(path.join(vendorDir, "build.zig.zon"), "utf8").match(
/minimum_zig_version\\s*=\\s*\"(\\d+\\.\\d+\\.\\d+)\"/
)
const minVersionMatch = readFileSync(path.join(vendorDir, "build.zig.zon"), "utf8").match(
/minimum_zig_version\s*=\s*"(\d+\.\d+\.\d+)"/
)
🤖 Prompt for AI Agents
In `@scripts/macos-ghostty-setup.ts` around lines 51 - 53, The regex in the
minVersionMatch extraction is double-escaped (e.g., "\\s", "\\d", "\\.") so it
never matches; update the RegExp passed to String.match in the readFileSync(...)
call (the expression assigning minVersionMatch) to use proper regex escapes
(single backslashes in a regex literal) so it matches minimum_zig_version =
"X.Y.Z" and captures the version into the existing capture group; ensure you
leave the surrounding call to readFileSync, path.join(vendorDir,
"build.zig.zon"), and the minVersionMatch variable intact.

Comment thread src/commands/usage.ts
Comment on lines +701 to +706
async function readPidFile(opts: { readonly path: string }): Promise<number | null> {
const file = Bun.file(opts.path)
if (!(await file.exists())) return null
const text = await file.text()
const value = Number.parseInt(text.trim(), 10)
return Number.isFinite(value) ? value : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the readPidFile function and surrounding context
cat -n src/commands/usage.ts | sed -n '695,715p'

Repository: hack-dance/hack

Length of output: 174


🏁 Script executed:

# Also search for where readPidFile is called to understand the impact
rg "readPidFile" src/commands/usage.ts -A 5 -B 2

Repository: hack-dance/hack

Length of output: 619


🏁 Script executed:

# Check if there are other similar file operations in the codebase with error handling
rg "Bun\.file\(" src/ -A 3 -B 1 | head -50

Repository: hack-dance/hack

Length of output: 3439


🏁 Script executed:

# Verify the exact implementation once more and check how it's used
cat -n src/commands/usage.ts | sed -n '700,710p'

Repository: hack-dance/hack

Length of output: 521


🏁 Script executed:

# Check what happens after the readPidFile call to understand impact
rg "readPidFile" src/commands/usage.ts -A 2 -B 1

Repository: hack-dance/hack

Length of output: 377


Guard against PID file read failures.
A permission/IO error on the PID file will currently throw and crash the command; return null instead. This is consistent with the function's return type Promise<number | null> and the calling code's expectation of null for unreadable files.

✅ Proposed fix
 async function readPidFile(opts: { readonly path: string }): Promise<number | null> {
-  const file = Bun.file(opts.path)
-  if (!(await file.exists())) return null
-  const text = await file.text()
-  const value = Number.parseInt(text.trim(), 10)
-  return Number.isFinite(value) ? value : null
+  try {
+    const file = Bun.file(opts.path)
+    if (!(await file.exists())) return null
+    const text = await file.text()
+    const value = Number.parseInt(text.trim(), 10)
+    return Number.isFinite(value) ? value : null
+  } catch {
+    return null
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function readPidFile(opts: { readonly path: string }): Promise<number | null> {
const file = Bun.file(opts.path)
if (!(await file.exists())) return null
const text = await file.text()
const value = Number.parseInt(text.trim(), 10)
return Number.isFinite(value) ? value : null
async function readPidFile(opts: { readonly path: string }): Promise<number | null> {
try {
const file = Bun.file(opts.path)
if (!(await file.exists())) return null
const text = await file.text()
const value = Number.parseInt(text.trim(), 10)
return Number.isFinite(value) ? value : null
} catch {
return null
}
}
🤖 Prompt for AI Agents
In `@src/commands/usage.ts` around lines 701 - 706, The readPidFile function
should guard against IO/permission errors and return null instead of allowing
exceptions to bubble; wrap the Bun.file(opts.path), await file.exists() and
await file.text() calls in a try/catch and on any caught error return null, then
keep the existing parsing logic (Number.parseInt(text.trim(), 10) and
Number.isFinite check) so readPidFile always resolves to a number or null even
when file access fails.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@scripts/build-macos-release.sh`:
- Around line 31-36: After sourcing the macOS app .env.local, validate that
APPLE_TEAM_ID, APPLE_ID and APPLE_ID_PASSWORD are set (non-empty) to avoid
unbound-variable failures under set -u; if any are missing, prompt the user (or
exit with a clear error) similar to apps/macos/scripts/build-release.sh. Apply
the same validation/prompt logic to the other credentials-loading block later in
the script (the repeated .env.local sourcing/credential usage section) so both
locations check the three environment variables before they are used.
♻️ Duplicate comments (1)
docs/cli.md (1)

742-744: Escape pipes inside table cells to fix column counts.

These rows still include unescaped | inside table cells, which markdownlint treats as extra columns. Escape them (e.g., \|) or replace with &vert; to keep the tables valid.

🧩 Example fixes
-| `-c`, `--client <cursor|claude|codex|print>` | string | - | Open init prompt in an agent client (or print) |
+| `-c`, `--client <cursor\|claude\|codex\|print>` | string | - | Open init prompt in an agent client (or print) |

-| `--scope <user|project>` | string | `user` | Write MCP config to user or project scope |
+| `--scope <user\|project>` | string | `user` | Write MCP config to user or project scope |

-| `--scope <user|project>` | string | `user` | Print MCP config for user or project scope |
+| `--scope <user\|project>` | string | `user` | Print MCP config for user or project scope |

-| `--format <auto|docker-compose|plain>` | string | `auto` | How to parse incoming lines from stdin |
+| `--format <auto\|docker-compose\|plain>` | string | `auto` | How to parse incoming lines from stdin |

-| `--stream <stdout|stderr>` | string | `stdout` | Treat stdin as stdout or stderr |
+| `--stream <stdout\|stderr>` | string | `stdout` | Treat stdin as stdout or stderr |

-| `--variant <cut|mash|cycle|random>` | string | `cycle` | Animation variant |
+| `--variant <cut\|mash\|cycle\|random>` | string | `cycle` | Animation variant |

Also applies to: 765-766, 783-784, 885-886, 936-938

🧹 Nitpick comments (6)
scripts/prepare-release.ts (1)

50-64: Consider distinguishing file-not-found from other errors.

The empty catch block silently swallows all errors, including permission issues or I/O failures on an existing file. For a release script, silently ignoring a failed write could lead to inconsistent versions.

💡 Suggested improvement
   try {
     const xconfigContent = await Bun.file(xconfigPath).text()
     const updatedXconfig = xconfigContent.replace(
       /^MARKETING_VERSION = .*/m,
       `MARKETING_VERSION = ${nextVersion}`
     )
     if (updatedXconfig !== xconfigContent) {
       await Bun.write(xconfigPath, updatedXconfig)
       process.stdout.write(`Updated Base.xcconfig: MARKETING_VERSION → ${nextVersion}\n`)
     }
-  } catch {
-    // macOS config may not exist, that's fine
+  } catch (err) {
+    // macOS config may not exist on non-macOS builds, that's fine
+    if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
+      process.stderr.write(`Warning: Failed to update Base.xcconfig: ${err.message}\n`)
+    }
   }
src/commands/daemon.ts (3)

261-346: Enhanced status reporting looks solid.

The status handler now provides comprehensive diagnostic information including API health check, launchd status, and structured reporting. The JSON output is well-designed for scripting.

Minor style note: Lines 323 and 326 use == for string comparison. Consider using === for consistency with TypeScript conventions.

♻️ Optional: Use strict equality
-    return report.status == "running" ? 0 : 1
+    return report.status === "running" ? 0 : 1
   }
 
-  if (report.status == "running") {
+  if (report.status === "running") {

417-437: Consider checking the stop result before starting.

The return value of handleDaemonStop is discarded on line 435. While the current implementation of handleDaemonStop always returns 0 on success, if error handling were added later, this restart handler would silently ignore stop failures and proceed to start anyway.

♻️ Suggested improvement
-  await handleDaemonStop({ ctx, args: stopArgs })
-  return await handleDaemonStart({ ctx, args: startArgs })
+  const stopResult = await handleDaemonStop({ ctx, args: stopArgs })
+  if (stopResult !== 0) {
+    logger.warn({ message: "Failed to stop hackd cleanly; attempting start anyway" })
+  }
+  return await handleDaemonStart({ ctx, args: startArgs })

467-477: Inconsistent flag precedence between option pairs.

The precedence order differs between the two option pairs:

  • runAtLoad: positive flag (--run-at-load) checked first
  • guiSessionOnly: negative flag (--no-gui-only) checked first

This means if a user mistakenly passes both flags:

  • --run-at-load --no-run-at-loadrunAtLoad = true
  • --gui-only --no-gui-onlyguiSessionOnly = false

Consider making the precedence consistent, or adding validation to reject conflicting flags.

♻️ Option A: Make precedence consistent (positive flag wins)
-  const guiSessionOnly = args.options["no-gui-only"] === true
-    ? false
-    : args.options["gui-only"] === true
-      ? true
-      : controlPlane.config.daemon.launchd.guiSessionOnly
+  const guiSessionOnly = args.options["gui-only"] === true
+    ? true
+    : args.options["no-gui-only"] === true
+      ? false
+      : controlPlane.config.daemon.launchd.guiSessionOnly
♻️ Option B: Reject conflicting flags
if (args.options["run-at-load"] && args.options["no-run-at-load"]) {
  logger.error({ message: "Cannot specify both --run-at-load and --no-run-at-load" })
  return 1
}
if (args.options["gui-only"] && args.options["no-gui-only"]) {
  logger.error({ message: "Cannot specify both --gui-only and --no-gui-only" })
  return 1
}
.github/workflows/release-macos-app.yml (2)

48-72: Quote shell variables to prevent word splitting.

Static analysis flagged unquoted variable expansions. While $RUNNER_TEMP is unlikely to contain spaces in GitHub Actions, quoting is defensive best practice.

🔧 Proposed fix
           # Import certificate
-          echo "$CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12
-          security import $RUNNER_TEMP/certificate.p12 \
+          echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
+          security import "$RUNNER_TEMP/certificate.p12" \
             -P "$CERTIFICATE_PASSWORD" \
             -A \
             -t cert \
             -f pkcs12 \
             -k "$KEYCHAIN_PATH"

87-112: Quote the output path in heredoc redirection.

🔧 Proposed fix
-          cat > $RUNNER_TEMP/ExportOptions.plist << EOF
+          cat > "$RUNNER_TEMP/ExportOptions.plist" << EOF

Comment on lines +31 to +36
# Load credentials from macOS app .env.local
if [ -f "$MACOS_APP_DIR/.env.local" ]; then
set -a
source "$MACOS_APP_DIR/.env.local"
set +a
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing validation for required Apple credentials.

The script sources .env.local but doesn't validate that required variables (APPLE_TEAM_ID, APPLE_ID, APPLE_ID_PASSWORD) are set before use. With set -u, this fails with an unclear "unbound variable" error. Unlike apps/macos/scripts/build-release.sh, this script doesn't prompt for missing credentials.

🔧 Proposed fix: Add validation after sourcing .env.local
 # Load credentials from macOS app .env.local
 if [ -f "$MACOS_APP_DIR/.env.local" ]; then
   set -a
   source "$MACOS_APP_DIR/.env.local"
   set +a
 fi
+
+# Validate required credentials
+[ -z "${APPLE_TEAM_ID:-}" ] && error "APPLE_TEAM_ID not set. Add it to $MACOS_APP_DIR/.env.local or export it."
+[ -z "${APPLE_ID:-}" ] && error "APPLE_ID not set (required for notarization)."
+[ -z "${APPLE_ID_PASSWORD:-}" ] && error "APPLE_ID_PASSWORD not set (required for notarization)."

Also applies to: 105-120

🤖 Prompt for AI Agents
In `@scripts/build-macos-release.sh` around lines 31 - 36, After sourcing the
macOS app .env.local, validate that APPLE_TEAM_ID, APPLE_ID and
APPLE_ID_PASSWORD are set (non-empty) to avoid unbound-variable failures under
set -u; if any are missing, prompt the user (or exit with a clear error) similar
to apps/macos/scripts/build-release.sh. Apply the same validation/prompt logic
to the other credentials-loading block later in the script (the repeated
.env.local sourcing/credential usage section) so both locations check the three
environment variables before they are used.

@roodboi roodboi changed the title Extension system feat: Extension system, Remote Gateway, macOS Desktop App, and comprehensive documentation Jan 21, 2026
@roodboi
roodboi merged commit d39c0b5 into main Jan 21, 2026
3 checks passed
@roodboi
roodboi deleted the extension-system branch January 21, 2026 03:46
roodboi pushed a commit that referenced this pull request Jan 21, 2026
## 1.2.0 (2026-01-21)

* adding control plane baseline and initial extensions ([6be8d20](6be8d20))
* build and release ([9e04011](9e04011))
* deps ([82fe249](82fe249))
* docker network routing + docs and gateway updates ([1677d42](1677d42))
* docs and tests ([fd96b18](fd96b18))
* docs and tests ([29619ae](29619ae))
* fix ticket tracking and auto fixing ([8f69f71](8f69f71))
* ignore internal ([6e9da24](6e9da24))
* initial tui ([ffec865](ffec865))
* Merge pull request #2 from hack-dance/extension-system ([d39c0b5](d39c0b5)), closes [#2](#2)
* notes ([fb49a67](fb49a67))
* resources and usage ([eafa98e](eafa98e))
* run ([9ac84cb](9ac84cb))
* tickets ([02b0d29](02b0d29))
* tickets and ci ([672cf6c](672cf6c))
* types ([01feefe](01feefe))
* types ([5503f0e](5503f0e))
* chore: gitignore build artifacts and auto-ignore .hack/.internal ([1b0b3fa](1b0b3fa))
* chore(macos): add helper scripts and docs ([8ed6a3f](8ed6a3f))
* feat: add hackd daemon with unix socket API, CLI commands, caching, and docs ([1d92ee2](1d92ee2))
* feat(daemon): add runtime health and reset detection ([80fcb4b](80fcb4b))
* feat(macos): add desktop app scaffold and mvp ([4b733e6](4b733e6))
* feat(macos): add hackd overview ([6c5ba01](6c5ba01))
* feat(tickets): sync tickets to hidden ref and repair legacy setup ([56c988b](56c988b))
* fix(macos): resolve hack cli path ([6e4b6ef](6e4b6ef))
* docs(macos): clarify project ownership ([a76d40f](a76d40f))
* ci: update macos runners ([33b0f48](33b0f48))
This was referenced Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant