Skip to content
4 changes: 3 additions & 1 deletion CLI/CMUXCLI+AutoNaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,9 @@ struct AutoNamingEngine: Sendable {
totalMessageCount: Int? = nil
) -> Int {
let count = max(messages.count, totalMessageCount ?? 0)
return count * config.minLineGrowth
// Empty caches stay ineligible; one initial prompt reaches the naming floor.
guard count > 0 else { return 0 }
return max(config.minTranscriptLines, count * config.minLineGrowth)
}

// MARK: - Prompt and response
Expand Down
24 changes: 24 additions & 0 deletions CLI/CMUXCLI+AutoNamingGenericHooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ extension CMUXCLI {
return engine.extractHookMessages(fromPayloadObjects: [object])
}

/// Starts a detached naming pass; the worker checks live settings and title ownership.
func spawnDetachedAgentAutoNameIfSupported(
def: AgentHookDef,
sessionId: String,
workspaceId: String,
surfaceId: String,
transcriptPath: String?,
cwd: String?,
env: [String: String],
telemetry: CLISocketSentryTelemetry
) {
guard autoNamingSource(for: def) != nil, !sessionId.isEmpty else { return }
spawnDetachedAgentAutoName(
def: def,
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
transcriptPath: transcriptPath,
cwd: cwd,
env: env,
telemetry: telemetry
)
}

/// Detached naming pass for non-Codex generic agents.
func runGenericAgentAutoNameHook(
def: AgentHookDef,
Expand Down
56 changes: 30 additions & 26 deletions CLI/CMUXCLI+AutoNamingHooks.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Darwin
import Foundation

extension CMUXCLI {
Expand Down Expand Up @@ -119,51 +118,56 @@ extension CMUXCLI {
telemetry: CLISocketSentryTelemetry
) {
let selfPath: String = {
if let first = ProcessInfo.processInfo.arguments.first,
first.hasPrefix("/"),
FileManager.default.isExecutableFile(atPath: first) {
return first
if let first = ProcessInfo.processInfo.arguments.first {
let resolved = URL(
fileURLWithPath: first,
relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
).standardizedFileURL.path
if isExecutableRegularFile(atPath: resolved) {
return resolved
}
}
if let bundled = normalizedHookValue(env["CMUX_BUNDLED_CLI_PATH"]),
FileManager.default.isExecutableFile(atPath: bundled) {
isExecutableRegularFile(atPath: bundled) {
return bundled
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return "cmux"
}()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = [
"-c",
"\"$0\" hooks \"$1\" auto-name --session \"$2\" --workspace \"$3\" --surface \"$4\" --transcript \"$5\" --cwd \"$6\" </dev/null >/dev/null 2>&1 &",
selfPath,
def.name,
sessionId,
workspaceId,
surfaceId,
transcriptPath ?? "",
cwd ?? ""
let hookArguments = [
"hooks", def.name, "auto-name",
"--session", sessionId,
"--workspace", workspaceId,
"--surface", surfaceId,
"--transcript", transcriptPath ?? "",
"--cwd", cwd ?? ""
]
let process = Process()
process.executableURL = URL(fileURLWithPath: selfPath.hasPrefix("/") ? selfPath : "/usr/bin/env")
process.arguments = selfPath.hasPrefix("/") ? hookArguments : [selfPath] + hookArguments
var spawnEnv = env
spawnEnv["CMUX_CLAUDE_HOOK_STATE_PATH"] = agentHookStatePath(sessionStoreSuffix: def.sessionStoreSuffix, env: env)
process.environment = spawnEnv
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
// Launch the bounded worker directly; the short-lived hook process must not own its lifetime.
try process.run()
Comment on lines 153 to 155

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)CLI/CMUXCLI\+AutoNamingHooks\.swift$|AutoNaming|auto-name|llmTimeout|summarize' || true

echo
echo "Target file outline:"
ast-grep outline CLI/CMUXCLI+AutoNamingHooks.swift --view expanded || true

echo
echo "Target file around relevant lines:"
if [ -f CLI/CMUXCLI+AutoNamingHooks.swift ]; then
  nl -ba CLI/CMUXCLI+AutoNamingHooks.swift | sed -n '1,240p'
fi

echo
echo "Search timeout/termination wrapper refs:"
rg -n "llmTimeout|summarize|auto-name|die|SIGKILL|process\.run|Process|run\(\)" CLI CodeRabbit Inc . --glob '!*.{png,jpg,jpeg,webp,gif,svg,aar,swiftinterface,xcstrings}' | head -n 250

Repository: manaflow-ai/cmux

Length of output: 1212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Install available line-number alternatives and inspect file:"
for f in CLI/CMUXCLI+AutoNamingHooks.swift CLI/CMUXCLI+.AutoNamingHooks.swift; do
  [ ${f%% *:-.} = "$f" ] || continue
done
wc -l CLI/CMUXCLI+AutoNamingHooks.swift
awk '{printf "%6d\t%s\n", NR, $0}' CLI/CMUXCLI+AutoNamingHooks.swift | sed -n '1,260p'

echo
echo "Search timeout/termination references:"
rg -n "llmTimeout|summarize|auto-name|SIGKILL|terminate\(|die|process\.run\(\)|Process\.init|run\(\)" -S . --glob '!*.{png,jpg,jpeg,webp,gif,svg,aar,swiftinterface,xcstrings}' || true

echo
echo "Diff around changed file if present:"
git diff -- CLI/CMUXCLI+AutoNamingHooks.swift | sed -n '1,240p' || true

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Limited source inspection for AutoNamingHooks and related auto-name files:"
for f in CLI/CMUXCLI+AutoNamingHooks.swift CLI/CMUXCLI+AutoNamingDispatch.swift CLI/CMUXCLI+AutoNamingGenericHooks.swift CLI/CMUXCLI+AutoNamingSummarizers.swift CLI/CMUXCLI+AutoNaming.swift; do
  if [ -f "$f" ]; then
    echo "===== $f: $(wc -l < "$f") lines ====="
  fi
done

echo "===== CLI/CMUXCLI+AutoNamingHooks.swift lines 1-240 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+AutoNamingHooks.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 240:
            print(f"{i:5d}\t{line}")
PY

echo "===== CLI/CMUXCLI+AutoNamingDispatch.swift lines 1-260 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+AutoNamingDispatch.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 260:
            print(f"{i:5d}\t{line}")
PY

echo "===== Search timeout/termination/cleanup refs excluding broad file types ====="
rg -n "llmTimeout|summarize|\.run\(\)|Process|SIGKILL|SIGTERM|terminate\(|kill|timeout|AutoNamingHooks|spawnDetachedAgentAutoName|runClaudeAutoNameHook|runCodexAutoNameHook" CLI --glob '*.swift' || true

echo "===== Git diff stats and hunks for renamed/deleted hook files ====="
git diff --stat -- '*AutoNamingHook*' '*AutoNamingDispatch*' || true
git diff -- '*AutoNamingHook*' '*AutoNamingDispatch*' | sed -n '1,260p' || true

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== CLI/CMUXCLI+AutoNamingGenericHooks.swift lines 1-220 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+AutoNamingGenericHooks.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 220:
            print(f"{i:5d}\t{line}")
PY

echo "===== CLI/CMUXCLI+AutoNamingSummarizers.swift lines 1-220 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+AutoNamingSummarizers.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 220:
            print(f"{i:5d}\t{line}")
PY

echo "===== CLI/CMUXCLI+AutoNaming.swift lines 1-260 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+AutoNaming.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 260:
            print(f"{i:5d}\t{line}")
PY

echo "===== CLI/CMUXCLI+Process.swift lines 290-500 ====="
python3 - <<'PY'
from pathlib import Path
p=Path('CLI/CMUXCLI+Process.swift')
if p.exists():
    for i,line in enumerate(p.read_text().splitlines(), 1):
        if 290 <= i <= 500:
            print(f"{i:5d}\t{line}")
PY

echo "===== Direct invocation helper usages ====="
rg -n "runAutoNamingSummarizer|runProcessData|runProcess\\(|cliRunProcess\\(|timeout:|process\\.run\\(|spawnDetachedAgentAutoName\\(|AgentHook|auto-name" CLI --glob '*.swift' | sed -n '1,220p'

Repository: manaflow-ai/cmux

Length of output: 50372


Add a process owner/deadline to the detached worker or apply runProcess with an existing timeout.

process.run() leaves spawnDetachedAgentAutoName with no owner or deadline. llmTimeout only covers the summary step inside the child; if the socket read, session-store check, or title apply stalls before then, the hook can leave an unbounded auto-name process. Keep direct execution, but preserve a whole-worker deadline, termination sequence, and SIGKILL cleanup in one of these entry points.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLI/CMUXCLI`+AutoNamingHooks.swift around lines 153 - 155, Update the
detached worker launch in spawnDetachedAgentAutoName to enforce a whole-worker
deadline, owner/lifetime handling, and termination escalation through SIGKILL
cleanup. Keep direct process execution, but reuse runProcess if it already
provides this timeout and cleanup behavior, ensuring stalls before the child’s
llmTimeout cannot leave the auto-name process running indefinitely.

} catch {
telemetry.breadcrumb("\(def.name)-hook.auto-name.spawn-failed")
return
}
if ((try? waitForProcessExit(process, timeout: 2)) ?? false) == false {
process.terminate()
if ((try? waitForProcessExit(process, timeout: 1)) ?? false) == false {
kill(process.processIdentifier, SIGKILL)
_ = try? waitForProcessExit(process, timeout: 1)
}
}
}

private func isExecutableRegularFile(atPath path: String) -> Bool {
// Resolve symlinks before rejecting non-regular filesystem entries.
let resolvedURL = URL(fileURLWithPath: path).resolvingSymlinksInPath()
guard let values = try? resolvedURL.resourceValues(forKeys: [.isRegularFileKey]),
values.isRegularFile == true else {
return false
}
return FileManager.default.isExecutableFile(atPath: path)
}

/// Detached Codex naming pass.
func runCodexAutoNameHook(
commandArgs: [String],
Expand Down
30 changes: 17 additions & 13 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31872,6 +31872,20 @@ export default CMUXSessionRestore;
)
}

// Message-backed agents can name the workspace from the first prompt while work continues.
if usesHookMessageCacheForAutoNaming(def), !suppressVisibleMutations {
spawnDetachedAgentAutoNameIfSupported(
def: def,
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
transcriptPath: normalizedHookValue(input.transcriptPath ?? mapped?.transcriptPath),
cwd: hookCwd ?? mapped?.cwd,
env: env,
telemetry: telemetry
)
}

case .stop:
if def.name == "codex", !sessionId.isEmpty {
let stopTurnId = input.turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
Expand Down Expand Up @@ -32208,19 +32222,9 @@ export default CMUXSessionRestore;
}
}

// Opt-in auto-naming for generic-agent sessions: a detached pass so the
// summarization subprocess never blocks this short sync hook.
// Gate the fork on the live setting (one cheap socket probe) so a
// disabled feature spawns nothing extra on turn end; the detached
// process re-probes to honor a toggle that lands mid-pass.
if autoNamingSource(for: def) != nil, !suppressVisibleMutations, !sessionId.isEmpty,
let autoNameProbe = try? client.sendV2(
method: "workspace.set_auto_title",
params: ["probe": true, "workspace_id": workspaceId]
),
autoNameProbe["enabled"] as? Bool == true,
autoNameProbe["workspace_user_owned"] as? Bool != true {
spawnDetachedAgentAutoName(
// Turn-end naming refreshes file-backed agents and incorporates the completed response.
if !suppressVisibleMutations {
spawnDetachedAgentAutoNameIfSupported(
def: def,
sessionId: sessionId,
workspaceId: workspaceId,
Expand Down
9 changes: 4 additions & 5 deletions cmuxTests/AutoNamingHookPayloadAdapterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,13 @@ import Testing
])
}

@Test func hookMessageLineEquivalentsReachSharedThrottleFloor() {
let messages = [
AutoNamingTranscriptMessage(role: "user", text: "Name this workspace"),
AutoNamingTranscriptMessage(role: "assistant", text: "I can summarize it.")
]
@Test func initialPromptReachesSharedThrottleFloor() {
// The first submitted prompt must be sufficient to start workspace naming.
let messages = [AutoNamingTranscriptMessage(role: "user", text: "Name this workspace")]

let lineCount = engine.hookMessageLineEquivalentCount(messages)
#expect(lineCount == engine.config.minTranscriptLines)
#expect(engine.hookMessageLineEquivalentCount([]) == 0)

let decision = engine.throttleDecision(
snapshot: AutoNamingSessionSnapshot(),
Expand Down
112 changes: 109 additions & 3 deletions cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,92 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
)
}

func testPiPromptSubmitStartsWorkspaceAutoNaming() throws {
let context = try makeClaudeHookContext(name: "pi-first-prompt-auto-name")
defer { context.cleanup() }

// Gate the Pi summarizer so the test controls when detached naming can finish.
let piURL = context.root.appendingPathComponent("pi", isDirectory: false)
let piStartedURL = context.root.appendingPathComponent("pi-started", isDirectory: false)
let piReleaseURL = context.root.appendingPathComponent("pi-release", isDirectory: false)
guard Darwin.mkfifo(piReleaseURL.path, 0o600) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
let piReleaseFD = Darwin.open(piReleaseURL.path, O_RDWR | O_CLOEXEC)
guard piReleaseFD >= 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var piWasReleased = false
let releasePiSummarizer = {
guard !piWasReleased else { return }
piWasReleased = true
// A newline releases the mock's blocking read exactly once.
_ = "\n".withCString { Darwin.write(piReleaseFD, $0, 1) }
}
defer {
releasePiSummarizer()
Darwin.close(piReleaseFD)
}
// The isolated paths avoid relying on credentials-safe summarizer environment variables.
try """
#!/bin/sh
: > "\(piStartedURL.path)"
IFS= read -r _ < "\(piReleaseURL.path)"
printf 'Java Workspace\\n'
""".write(
to: piURL,
atomically: true,
encoding: .utf8
)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: piURL.path)
startAgentHookMockServerAccepting(
context: context,
workspaceAutoTitleResult: [
"enabled": true,
"workspace_user_owned": false,
"workspace_applied": true,
]
)

let result = runAgentHook(
context: context,
agent: "pi",
subcommand: "prompt-submit",
standardInput: #"{"session_id":"pi-first-prompt","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"Fix the Java build"}"#,
extraEnvironment: [
"PATH": "\(context.root.path):/usr/bin:/bin:/usr/sbin:/sbin",
]
)

XCTAssertFalse(result.timedOut, "The prompt hook must return before Pi completes: \(result.stderr)")
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(
waitForConditionBlocking(timeout: 5) { FileManager.default.fileExists(atPath: piStartedURL.path) },
"The Pi summarizer must reach the controlled release point."
)
XCTAssertFalse(
context.state.snapshot().compactMap(self.jsonObject).contains { payload in
guard payload["method"] as? String == "workspace.set_auto_title",
let params = payload["params"] as? [String: Any] else {
return false
}
return params["title"] != nil
},
"Auto-naming must remain blocked until the test releases Pi."
)

releasePiSummarizer()
XCTAssertTrue(waitForConditionBlocking(timeout: 5) {
context.state.snapshot().compactMap(self.jsonObject).contains { payload in
guard payload["method"] as? String == "workspace.set_auto_title",
let params = payload["params"] as? [String: Any] else {
return false
}
return params["title"] as? String == "Java Workspace"
}
}, "The first submitted Pi prompt must start and apply workspace auto-naming.")
}

func testClaudePreToolUseFeedContextReadsOnlyRecentTranscriptTail() throws {
let context = try makeClaudeHookContext(name: "claude-pretool-tail")
defer { context.cleanup() }
Expand Down Expand Up @@ -9158,10 +9244,17 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
/// Serves this context's agent-hook mock socket for the rest of the test. One
/// accept loop answers every connection, including the CLI's extra `system.top`
/// lookup connection, and the registry reaps the loop at teardown.
private func startAgentHookMockServerAccepting(context: ClaudeHookContext) {
private func startAgentHookMockServerAccepting(
context: ClaudeHookContext,
workspaceAutoTitleResult: [String: Any]? = nil
) {
let state = context.state
let mockResponse: @Sendable (String) -> String = { line in
self.agentHookMockResponse(line: line, context: context)
self.agentHookMockResponse(
line: line,
context: context,
workspaceAutoTitleResult: workspaceAutoTitleResult
)
}
CLIMockAcceptLoopRegistry.shared.start(listenerFD: context.listenerFD, onConnection: { clientFD in
defer { Darwin.close(clientFD) }
Expand All @@ -9172,7 +9265,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
}, onListenerClosed: {})
}

private func agentHookMockResponse(line: String, context: ClaudeHookContext) -> String {
private func agentHookMockResponse(
line: String,
context: ClaudeHookContext,
workspaceAutoTitleResult: [String: Any]? = nil
) -> String {
guard let payload = jsonObject(line) else {
return "OK"
}
Expand All @@ -9188,6 +9285,15 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
return v2Response(id: id, ok: true, result: ["resume_binding": [:]])
case "surface.resume.clear":
return v2Response(id: id, ok: true, result: ["cleared": true])
case "workspace.set_auto_title":
guard let workspaceAutoTitleResult else {
return v2Response(
id: id,
ok: false,
error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]
)
}
return v2Response(id: id, ok: true, result: workspaceAutoTitleResult)
default:
return v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"])
}
Expand Down