Skip to content

Commit a9547f4

Browse files
committed
feat: name and resume remote Codex chats
1 parent e1e5139 commit a9547f4

6 files changed

Lines changed: 138 additions & 13 deletions

File tree

Integrations/noturcode-agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"WEZTERM_UNIX_SOCKET",
4040
"KITTY_WINDOW_ID",
4141
"KITTY_LISTEN_ON",
42+
"NOTURCODE_SESSION_NAME",
4243
)
4344

4445

Integrations/noturcode-cli.zsh

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ step() {
1717
print -- "[$1] $2"
1818
}
1919

20+
settle() {
21+
local label="$1"
22+
if [[ -t 1 ]]; then
23+
local frame
24+
for frame in '[> ]' '[=> ]' '[==> ]' '[===>]'; do
25+
printf '\r %s %s' "$frame" "$label"
26+
sleep 0.025
27+
done
28+
printf '\r [ ok ] %s\n' "$label"
29+
else
30+
print -- " [ ok ] $label"
31+
fi
32+
}
33+
2034
valid_host() {
2135
[[ -n "$1" && "$1" != -* && "$1" != *[$'\n\r\t ']* ]]
2236
}
@@ -78,6 +92,23 @@ read_host() {
7892
print -r -- "$value"
7993
}
8094

95+
read_chat_name() {
96+
local default_name="$1"
97+
local value=""
98+
read "value?Chat name [$default_name]: "
99+
value="${value:-$default_name}"
100+
if [[ -z "$value" || ${#value} -gt 80 || "$value" == *[$'\n\r\t']* || "$value" == *"'"* ]]; then
101+
fail "Use 1-80 characters. Do not use quotes or line breaks."
102+
return 1
103+
fi
104+
print -r -- "$value"
105+
}
106+
107+
sync_remote_agent() {
108+
local host="$1"
109+
ssh "$host" 'umask 077; mkdir -p "$HOME/.local/bin"; cat > "$HOME/.local/bin/noturcode-agent"; chmod 700 "$HOME/.local/bin/noturcode-agent"' < "$remote_agent"
110+
}
111+
81112
pair_host() {
82113
local host="${1:-}"
83114
if [[ -z "$host" ]]; then
@@ -94,11 +125,12 @@ pair_host() {
94125
start_proxy "$local_socket" pair || return 1
95126

96127
step 1 "Copy the free helper to $host"
97-
if ! ssh "$host" 'umask 077; mkdir -p "$HOME/.local/bin"; cat > "$HOME/.local/bin/noturcode-agent"; chmod 700 "$HOME/.local/bin/noturcode-agent"' < "$remote_agent"; then
128+
if ! sync_remote_agent "$host"; then
98129
stop_proxy
99130
fail "Could not copy the helper to $host."
100131
return 1
101132
fi
133+
settle "Helper copied"
102134

103135
step 2 "Pair with one-time code $code"
104136
if ! ssh -tt \
@@ -112,28 +144,51 @@ pair_host() {
112144
return 1
113145
fi
114146
stop_proxy
147+
settle "VPS paired"
115148

116149
step 3 "Ready. Run nc, then choose Open an SSH workspace."
117150
}
118151

119152
connect_host() {
120153
local host="${1:-}"
154+
local mode="${2:-shell}"
155+
local chat_name="${3:-}"
121156
if [[ -z "$host" ]]; then
122157
host=$(read_host) || return 1
123158
fi
124159
valid_host "$host" || fail "Invalid SSH host or alias." || return 1
160+
if [[ -z "$chat_name" ]]; then
161+
chat_name=$(read_chat_name "$host") || return 1
162+
fi
125163
ensure_app || return 1
126164

165+
step 1 "Sync the remote helper on $host"
166+
if ! sync_remote_agent "$host"; then
167+
fail "Could not update the helper on $host."
168+
return 1
169+
fi
170+
settle "Helper ready"
171+
127172
local local_socket terminal_id remote_socket remote_command
128173
local_socket=$("$bridge" socket-path) || return 1
129174
terminal_id=$("$bridge" terminal-id) || return 1
130175
remote_socket="/tmp/noturcode-${USER//[^A-Za-z0-9_-]/_}-$$.sock"
131-
remote_command="export NOTURCODE_REMOTE_SOCKET='$remote_socket'; export NOTURCODE_TERMINAL_SESSION_ID='$terminal_id'; export NOTURCODE_REMOTE_HOST='$host'; exec \"\${SHELL:-/bin/sh}\" -l"
176+
if [[ "$mode" == "resume" ]]; then
177+
remote_command="export NOTURCODE_REMOTE_SOCKET='$remote_socket'; export NOTURCODE_TERMINAL_SESSION_ID='$terminal_id'; export NOTURCODE_REMOTE_HOST='$host'; export NOTURCODE_SESSION_NAME='$chat_name'; exec \"\${SHELL:-/bin/sh}\" -lc 'codex resume --all'"
178+
else
179+
remote_command="export NOTURCODE_REMOTE_SOCKET='$remote_socket'; export NOTURCODE_TERMINAL_SESSION_ID='$terminal_id'; export NOTURCODE_REMOTE_HOST='$host'; export NOTURCODE_SESSION_NAME='$chat_name'; exec \"\${SHELL:-/bin/sh}\" -l"
180+
fi
132181
start_proxy "$local_socket" session || return 1
133182

134-
step 1 "Open the encrypted tunnel"
135-
step 2 "Start the interactive shell on $host"
136-
step 3 "Run Claude, Codex, or Gemini. Use /nc NAME inside the agent."
183+
step 2 "Open the encrypted tunnel"
184+
settle "Tunnel ready"
185+
if [[ "$mode" == "resume" ]]; then
186+
step 3 "Open the Codex chat picker on $host"
187+
step 4 "Select a chat. Noturcode will show it as $chat_name."
188+
else
189+
step 3 "Start the interactive shell on $host"
190+
step 4 "Run your coding agent. Noturcode will show it as $chat_name."
191+
fi
137192
ssh -tt \
138193
-o ExitOnForwardFailure=yes \
139194
-o StreamLocalBindUnlink=yes \
@@ -144,6 +199,15 @@ connect_host() {
144199
return "$status"
145200
}
146201

202+
resume_codex() {
203+
local host="${1:-}"
204+
local chat_name="${2:-}"
205+
if [[ -z "$host" ]]; then
206+
host=$(read_host) || return 1
207+
fi
208+
connect_host "$host" resume "$chat_name"
209+
}
210+
147211
doctor() {
148212
ensure_app || return 1
149213
"$bridge" doctor
@@ -157,29 +221,33 @@ menu() {
157221
print -- "Noturcode remote"
158222
print -- " 1. Pair a VPS"
159223
print -- " 2. Open an SSH workspace"
160-
print -- " 3. Check setup"
161-
print -- " 4. Exit"
224+
print -- " 3. Resume an existing Codex chat"
225+
print -- " 4. Check setup"
226+
print -- " 5. Exit"
162227
local choice=""
163-
read "choice?Choose 1-4: "
228+
read "choice?Choose 1-5: "
164229
case "$choice" in
165230
1) pair_host || true ;;
166231
2) connect_host; return $? ;;
167-
3) doctor || true ;;
168-
4) return 0 ;;
169-
*) print -u2 -- "Choose 1, 2, 3, or 4." ;;
232+
3) resume_codex; return $? ;;
233+
4) doctor || true ;;
234+
5) return 0 ;;
235+
*) print -u2 -- "Choose 1, 2, 3, 4, or 5." ;;
170236
esac
171237
done
172238
}
173239

174240
case "${1:-}" in
175241
"") menu ;;
176242
pair) shift; pair_host "${1:-}" ;;
177-
ssh) shift; connect_host "${1:-}" ;;
243+
ssh) shift; connect_host "${1:-}" shell "${2:-}" ;;
244+
resume) shift; resume_codex "${1:-}" "${2:-}" ;;
178245
doctor) doctor ;;
179246
help|-h|--help)
180247
print -- "nc Interactive setup"
181248
print -- "nc pair [host] Pair one VPS"
182249
print -- "nc ssh [host] Open one tracked SSH shell"
250+
print -- "nc resume [host] Resume an existing Codex chat"
183251
print -- "nc doctor Check the local setup"
184252
;;
185253
*) fail "Unknown command. Run nc for the guided setup." ;;

Integrations/noturcode-shell.zsh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Generated by Noturcode. Arguments not owned by Noturcode still use macOS netcat.
22
nc() {
33
case "${1:-}" in
4-
""|pair|ssh|doctor|help|-h|--help)
4+
""|pair|ssh|resume|doctor|help|-h|--help)
55
"$HOME/Library/Application Support/Noturcode/bin/noturcode-cli" "$@"
66
;;
77
*)

Sources/NoturcodeCore/HookNormalizer.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ public enum HookNormalizer {
109109
let tokens = payload.recursivelySummedTokens()
110110
let agentID = payload.firstString(for: ["agent_id", "agentId"])
111111
let agentType = payload.firstString(for: ["agent_type", "agentType"]) ?? "agent"
112+
let sessionName = environment["NOTURCODE_SESSION_NAME"]?
113+
.components(separatedBy: .newlines)
114+
.lazy
115+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
116+
.first(where: { !$0.isEmpty })
112117

113118
func event(
114119
_ kind: BridgeEventKind,
@@ -123,6 +128,7 @@ public enum HookNormalizer {
123128
source: source,
124129
sessionID: sessionID,
125130
timestamp: now,
131+
name: sessionName,
126132
terminalSessionID: terminalSessionID,
127133
sourceProcessID: sourceProcessID,
128134
cwd: cwd,

Sources/NoturcodeCore/SessionStore.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public final class SessionStore: ObservableObject {
124124
}
125125
session.sourceProcessID = event.sourceProcessID ?? session.sourceProcessID
126126
session.transcriptPath = event.transcriptPath ?? session.transcriptPath
127+
session.name = Self.normalizedSessionName(event.name) ?? session.name
127128
if let terminal = event.terminalSessionID, !terminal.isEmpty {
128129
session.terminal = TerminalTarget(sessionID: terminal)
129130
}

Tests/NoturcodeCoreTests/NoturcodeCoreTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,26 @@ final class NoturcodeCoreTests: XCTestCase {
307307
)
308308
}
309309

310+
func testRemoteSessionStartUsesTheNameChosenByNC() throws {
311+
let result = HookNormalizer.normalize(
312+
payload: .object([
313+
"hook_event_name": .string("SessionStart"),
314+
"session_id": .string("remote-named-session")
315+
]),
316+
source: .codex,
317+
environment: [
318+
"PWD": "/root",
319+
"NOTURCODE_SESSION_NAME": "GPRC deploy"
320+
],
321+
sourceProcessID: 44,
322+
terminalSessionIDOverride: "w0t1:REMOTE",
323+
now: Date(timeIntervalSince1970: 1_800_000_000)
324+
)
325+
326+
XCTAssertEqual(result.events.first?.kind, .sessionStarted)
327+
XCTAssertEqual(result.events.first?.name, "GPRC deploy")
328+
}
329+
310330
func testInteractiveNCIntegrationPreservesNetcatAndGuidesPairing() throws {
311331
let repository = URL(fileURLWithPath: #filePath)
312332
.deletingLastPathComponent()
@@ -320,10 +340,16 @@ final class NoturcodeCoreTests: XCTestCase {
320340
XCTAssertTrue(shell.contains("command /usr/bin/nc"))
321341
XCTAssertTrue(cli.contains("Pair a VPS"))
322342
XCTAssertTrue(cli.contains("Open an SSH workspace"))
343+
XCTAssertTrue(cli.contains("Resume an existing Codex chat"))
344+
XCTAssertTrue(cli.contains("Chat name"))
345+
XCTAssertTrue(cli.contains("codex resume --all"))
346+
XCTAssertTrue(cli.contains("settle()"))
347+
XCTAssertTrue(cli.contains("[===>]"))
323348
XCTAssertTrue(cli.contains("StreamLocalBindUnlink=yes"))
324349
XCTAssertTrue(cli.contains("pair-code"))
325350
XCTAssertTrue(agent.contains("remotePair"))
326351
XCTAssertTrue(agent.contains("remoteHook"))
352+
XCTAssertTrue(agent.contains("NOTURCODE_SESSION_NAME"))
327353
XCTAssertTrue(agent.contains("Hooks must fail open"))
328354
}
329355

@@ -1491,6 +1517,29 @@ final class NoturcodeCoreTests: XCTestCase {
14911517
XCTAssertEqual(session.state, .idle)
14921518
}
14931519

1520+
@MainActor
1521+
func testNamedRemoteSessionReplacesTheRootDirectoryFallback() throws {
1522+
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
1523+
let store = SessionStore(persistence: SessionPersistence(fileURL: directory.appendingPathComponent("sessions.json")))
1524+
_ = store.apply(BridgeEvent(
1525+
kind: .sessionStarted,
1526+
source: .codex,
1527+
sessionID: "remote-existing",
1528+
terminalSessionID: "w0t2:REMOTE",
1529+
cwd: "/root"
1530+
))
1531+
let renamed = store.apply(BridgeEvent(
1532+
kind: .sessionStarted,
1533+
source: .codex,
1534+
sessionID: "remote-existing",
1535+
name: "GPRC deploy",
1536+
terminalSessionID: "w0t2:REMOTE",
1537+
cwd: "/root"
1538+
))
1539+
1540+
XCTAssertEqual(renamed?.new?.name, "GPRC deploy")
1541+
}
1542+
14941543
@MainActor
14951544
func testSessionStoreAcceptsAndPersistsNativeSessionWithoutTerminal() throws {
14961545
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)

0 commit comments

Comments
 (0)