APK turn cancel + session resume + history replay
ober
591af44a984b9fbd2322542d238e3cdb783b6698
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt @@ -29,11 +29,16 @@ class JcodeClient(private val context: Context) { interface Listener { fun onConnected() fun onReady(sessionId: String, title: String) + fun onHistoryUser(content: String) + fun onHistoryAssistant(content: String) + fun onHistoryTool(name: String, argsJson: String) + fun onHistoryEnd() fun onToken(text: String) fun onToolStart(name: String, argsJson: String) fun onToolEnd(name: String) fun onTurnEnd(sessionId: String) fun onError(message: String) + fun onCancelled() fun onDisconnected(reason: String) fun onAuthFailed(message: String) fun onConfig(provider: String, model: String, providers: List<String>) @@ -221,6 +226,10 @@ class JcodeClient(private val context: Context) { send(JSONObject().put("type", "get_status")) } + fun sendCancel() { + send(JSONObject().put("type", "cancel")) + } + // ── internal ──────────────────────────────────────────────────────── private fun writerLoop(writer: BufferedWriter) { @@ -268,6 +277,20 @@ class JcodeClient(private val context: Context) { obj.optString("session_id", ""), obj.optString("session_title", "") ) + "history" -> { + val role = obj.optString("role", "") + val content = obj.optString("content", "") + when (role) { + "user" -> l.onHistoryUser(content) + "assistant" -> l.onHistoryAssistant(content) + } + } + "history_tool" -> { + val argsRaw = obj.opt("args") + val argsStr = argsRaw?.toString() ?: "{}" + l.onHistoryTool(obj.optString("name", "?"), argsStr) + } + "history_end" -> l.onHistoryEnd() "token" -> l.onToken(obj.optString("text", "")) "tool_start" -> l.onToolStart( obj.optString("name", "?"), @@ -275,6 +298,7 @@ class JcodeClient(private val context: Context) { ) "tool_end" -> l.onToolEnd(obj.optString("name", "?")) "turn_end" -> l.onTurnEnd(obj.optString("session_id", "")) + "cancelled" -> l.onCancelled() "error" -> l.onError(obj.optString("message", "unknown error")) "config" -> { val providers = mutableListOf<String>() --- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt @@ -153,10 +153,12 @@ class MainActivity : Activity(), JcodeClient.Listener { btnProvider.setOnClickListener { showProviderPicker() } btnModel.setOnClickListener { showModelPicker() } - btnSend.setOnClickListener { trySend() } + btnSend.setOnClickListener { + if (sending) tryCancel() else trySend() + } inputText.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_SEND) { - trySend() + if (!sending) trySend() true } else false } @@ -257,7 +259,20 @@ class MainActivity : Activity(), JcodeClient.Listener { private fun setInputEnabled(enabled: Boolean) { inputText.isEnabled = enabled - btnSend.isEnabled = enabled && !sending + // Send button stays clickable while sending so the user can hit Stop. + btnSend.isEnabled = enabled + } + + private fun setSending(active: Boolean) { + sending = active + if (active) { + btnSend.text = getString(R.string.stop) + btnSend.backgroundTintList = resources.getColorStateList(R.color.mode_plan, theme) + } else { + btnSend.text = getString(R.string.send) + btnSend.backgroundTintList = resources.getColorStateList(R.color.accent, theme) + } + btnSend.isEnabled = client.isConnected() } private fun appendUser(text: String) { @@ -387,8 +402,7 @@ class MainActivity : Activity(), JcodeClient.Listener { return } - sending = true - btnSend.isEnabled = false + setSending(true) appendUser(text) // Reset tool count for new turn toolCount = 0 @@ -399,6 +413,14 @@ class MainActivity : Activity(), JcodeClient.Listener { inputText.text = Editable.Factory.getInstance().newEditable("") } + private fun tryCancel() { + if (!sending) return + if (!client.isConnected()) return + client.sendCancel() + setStatus(getString(R.string.status_cancelling)) + // Server will reply with {cancelled} + {turn_end}; UI state resets there. + } + // ── JcodeClient.Listener (main thread) ────────────────────────────── override fun onConnected() { @@ -410,10 +432,40 @@ class MainActivity : Activity(), JcodeClient.Listener { } override fun onReady(sessionId: String, title: String) { + // Server may follow `ready` with `history` events to repopulate the + // chat. Clear stale views first so reconnecting to the same session + // (banner reconnect or app relaunch) doesn't double-render messages. + chatContainer.removeAllViews() + toolsContainer.removeAllViews() + toolCount = 0 + updateTabLabels() + currentAssistantText = null + currentAssistantBuf.setLength(0) + if (title.isNotEmpty()) titleText.text = title setStatus("Ready \u00b7 session ${sessionId.take(8)}") } + override fun onHistoryUser(content: String) { + appendUser(content) + } + + override fun onHistoryAssistant(content: String) { + // Replay: render the full content as a non-streaming assistant bubble. + val view = layoutInflater.inflate(R.layout.item_chat_assistant, chatContainer, false) + view.findViewById<TextView>(R.id.message_text).text = content + chatContainer.addView(view) + throttledScroll() + } + + override fun onHistoryTool(name: String, argsJson: String) { + appendTool(name, argsJson) + } + + override fun onHistoryEnd() { + throttledScroll() + } + override fun onToken(text: String) { appendAssistantToken(text) } @@ -430,16 +482,19 @@ class MainActivity : Activity(), JcodeClient.Listener { override fun onTurnEnd(sessionId: String) { finalizeAssistant() hideStatus() - sending = false - btnSend.isEnabled = true + setSending(false) } override fun onError(message: String) { appendError(message) finalizeAssistant() hideStatus() - sending = false - btnSend.isEnabled = true + setSending(false) + } + + override fun onCancelled() { + // Render an inline marker so the user sees the turn was interrupted. + appendError("(interrupted)") } override fun onDisconnected(reason: String) { @@ -449,7 +504,7 @@ class MainActivity : Activity(), JcodeClient.Listener { infoRow.visibility = View.GONE finalizeAssistant() hideStatus() - sending = false + setSending(false) } override fun onAuthFailed(message: String) { --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ <string name="app_name">jcode</string> <string name="settings">Settings</string> <string name="send">Send</string> + <string name="stop">Stop</string> + <string name="status_cancelling">Cancelling\u2026</string> <string name="mode_plan">Plan</string> <string name="mode_build">Build</string> <string name="hint_message">Message jcode\u2026</string> --- a/src/jcode/core/agent.ss +++ b/src/jcode/core/agent.ss @@ -1050,6 +1050,11 @@ Be concise. Prefer edit over write for modifying existing files. (def (agent-run session-id user-input) (log-info logger "agent-run" `((session . ,session-id))) + ;; Defensively repair the session before the new turn — a previously + ;; cancelled or crashed turn may have left an assistant tool_calls + ;; message without matching tool result messages, which the + ;; OpenAI-style API rejects with a 400. + (try (session-repair-orphan-tool-calls! session-id) (catch (_) (void))) (let ((existing (session-get-messages session-id))) (when (null? existing) (session-add-message session-id (make-system-message (system-prompt))))) @@ -1218,12 +1223,37 @@ Be concise. Prefer edit over write for modifying existing files. (when cb (cb 'end name args)) (make-tool-result (tool-call-id tc) result)))))) +(def *tools-incompat-warned* (make-hash-table)) + +(def (auto-swap-incompatible-model provider-name model) + ;; If the chosen model can't accept the tools API, swap to the provider's + ;; default tool-capable model and warn ONCE per (provider,model) pair. + ;; Without this, deepseek-reasoner silently strips tools and the model + ;; spirals through hallucinated text-form calls. + (cond + ((not (model-rejects-tools? model)) model) + (else + (let ((replacement (config-default-model provider-name)) + (key (string-append provider-name "/" model))) + (cond + ((or (not replacement) (model-rejects-tools? replacement)) model) + (else + (unless (hash-get *tools-incompat-warned* key) + (hash-put! *tools-incompat-warned* key #t) + (log-warn logger "model-no-tools-auto-swap" + `((from . ,model) (to . ,replacement) (provider . ,provider-name))) + (fprintf (current-error-port) + "[jcode] Model '~a' does not support tool calling — using '~a' instead.\n" + model replacement)) + replacement)))))) + (def (get-current-provider) (let* ((provider-name (or (current-provider-override) (config-provider))) (api-key (config-get-provider-key provider-name)) - (model (or (current-model-override) - (config-ref "model") - (config-default-model provider-name)))) + (configured (or (current-model-override) + (config-ref "model") + (config-default-model provider-name))) + (model (auto-swap-incompatible-model provider-name configured))) (make-provider provider-name api-key model))) (def (agent-chat user-input) --- a/src/jcode/core/log.ss +++ b/src/jcode/core/log.ss @@ -81,7 +81,8 @@ (suffix (if (null? data) "" (string-append " " (format-alist data))))) (with-mutex *log-mutex* (when (level-enabled? level (*log-level*)) - (fprintf (current-error-port) "~a~a~n" line suffix)) + (fprintf (current-error-port) "~a~a~n" line suffix) + (flush-output-port (current-error-port))) (when *trace-port* (fprintf *trace-port* "~a ~a~a~n" (trace-timestamp) line suffix))))) --- a/src/jcode/core/session.ss +++ b/src/jcode/core/session.ss @@ -10,6 +10,7 @@ session-replace-messages session-update-title session-delete + session-repair-orphan-tool-calls! session-id session-title session-created @@ -212,6 +213,59 @@ (vector-ref row 2))) rows))))) +(def (session-repair-orphan-tool-calls! session-id) + "Repair a history that ends with an assistant tool_calls message that + doesn't have matching tool result messages — happens after a turn + was cancelled mid-tool-execution. The OpenAI-style API requires every + tool_call_id to be answered, so we synthesize a '(cancelled)' tool + result for each unmatched id." + (with-db + (lambda (db) + (let ((rows (sqlite-query db + "SELECT id, role, tool_calls, tool_call_id + FROM messages WHERE session_id = ? + ORDER BY id DESC LIMIT 16" + session-id))) + (when (pair? rows) + (let* ((rev rows) ;; rows are already DESC; treat as recent-first + (answered ;; tool_call_ids that already have a tool result + (let loop ((rs rev) (acc '())) + (cond + ((null? rs) acc) + ((equal? (vector-ref (car rs) 1) "tool") + (loop (cdr rs) (cons (vector-ref (car rs) 3) acc))) + (else acc)))) ;; stop scan at first non-tool message + (last-asst + ;; Find the most-recent assistant message in this scan window. + (let loop ((rs rev)) + (cond + ((null? rs) #f) + ((equal? (vector-ref (car rs) 1) "assistant") (car rs)) + (else (loop (cdr rs))))))) + (when (and last-asst + (let ((tcs-json (vector-ref last-asst 2))) + (and tcs-json (not (equal? tcs-json ""))))) + (let* ((parsed (try (string->json-object (vector-ref last-asst 2)) + (catch (_) '()))) + (ids (if (pair? parsed) + (map (lambda (j) (hash-ref j "id" #f)) parsed) + '())) + (missing (filter (lambda (id) + (and id (not (member id answered)))) + ids))) + (when (pair? missing) + (let ((now (timestamp-now))) + (for-each + (lambda (id) + (sqlite-eval db + "INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, created_at) + VALUES (?, 'tool', '(cancelled)', NULL, ?, ?)" + session-id id now)) + missing) + (sqlite-eval db + "UPDATE sessions SET updated_at = ? WHERE id = ?" + now session-id))))))))))) + (def (session-delete session-id) (with-db (lambda (db) --- a/src/jcode/provider/provider.ss +++ b/src/jcode/provider/provider.ss @@ -10,7 +10,8 @@ provider-list-pricing provider-name provider-model - provider-base-url) + provider-base-url + model-rejects-tools?) (import :std/text/json :std/net/request --- a/src/jcode/ui/serve.ss +++ b/src/jcode/ui/serve.ss @@ -10,15 +10,20 @@ ;;; {"type":"new_session","title":"..."} start new session ;;; {"type":"switch_session","id":"..."} switch existing ;;; {"type":"list_sessions"} enumerate sessions +;;; {"type":"cancel"} abort in-flight turn ;;; {"type":"ping"} heartbeat ;;; ;;; ── Output events (jcode → client) ───────────────────────────────────── ;;; {"type":"auth_ok"} TCP-only: auth accepted ;;; {"type":"ready","session_id":"...","session_title":"..."} +;;; {"type":"history","role":"user|assistant","content":"..."} +;;; {"type":"history_tool","name":"...","args":"<json-string>"} +;;; {"type":"history_end"} end of history replay ;;; {"type":"token","text":"..."} streaming text delta ;;; {"type":"tool_start","name":"...","args":{...}} ;;; {"type":"tool_end","name":"..."} ;;; {"type":"turn_end","session_id":"..."} +;;; {"type":"cancelled"} turn aborted by client ;;; {"type":"error","message":"..."} ;;; {"type":"session","id":"...","title":"...","created":"..."} ;;; {"type":"sessions_end"} @@ -39,6 +44,7 @@ :jcode/core/config :jcode/core/models :jcode/core/session + :jcode/core/message :jcode/core/agent :jcode/tool/registry :jcode/mcp/client @@ -60,13 +66,29 @@ ;; ── event I/O ────────────────────────────────────────────────────────── +;; Serializes writes to *serve-out* so the worker thread (streaming tokens +;; during a turn) and the reader thread (handling control events) can't +;; interleave half-lines on the wire. Uses the Gambit-compatible +;; mutex-lock!/mutex-unlock! pair from :std/misc/thread because the make-mutex +;; in scope wraps the Chez native mutex in a gerbil-mutex struct, so the +;; Chez `with-mutex` macro can't operate on it. +(def *out-mutex* (make-mutex)) + +(def (with-out-lock thunk) + (mutex-lock! *out-mutex*) + (try + (thunk) + (finally (mutex-unlock! *out-mutex*)))) + (def (emit-event type pairs) (let ((ht (make-hash-table))) (hash-put! ht "type" type) (for-each (lambda (p) (hash-put! ht (car p) (cdr p))) pairs) - (display (json-object->string ht) (*serve-out*)) - (newline (*serve-out*)) - (flush-output-port (*serve-out*)))) + (with-out-lock + (lambda () + (display (json-object->string ht) (*serve-out*)) + (newline (*serve-out*)) + (flush-output-port (*serve-out*)))))) (def (emit-error msg) (emit-event "error" `(("message" . ,msg)))) @@ -84,36 +106,19 @@ (thunk) (finally (apply-mode! "build")))) -;; ── callbacks wired into agent-run ───────────────────────────────────── - -(def (serve-stream-cb token) - (emit-event "token" `(("text" . ,token)))) - -(def (serve-tool-cb phase name args) - (cond - ((eq? phase 'start) - (emit-event "tool_start" - `(("name" . ,name) - ("args" . ,args)))) - ((eq? phase 'end) - (emit-event "tool_end" `(("name" . ,name)))))) - -(def (serve-usage-cb usage) - (let ((tin 0) (tout 0) (cost 0.0)) - (for-each - (lambda (pair) - (case (car pair) - ((tokens-in) (set! tin (cdr pair))) - ((tokens-out) (set! tout (cdr pair))) - ((cost) (set! cost (cdr pair))))) - usage) - (set! *session-tokens-in* (+ *session-tokens-in* tin)) - (set! *session-tokens-out* (+ *session-tokens-out* tout)) - (set! *session-cost* (+ *session-cost* cost)) - (emit-event "usage" - `(("tokens_in" . ,*session-tokens-in*) - ("tokens_out" . ,*session-tokens-out*) - ("cost" . ,*session-cost*))))) +;; ── cancellation ─────────────────────────────────────────────────────── +;; +;; Cancel uses the same orphan-and-bump trick as the TUI: cancel doesn't +;; wait for the worker to unblock. handle-cancel sets *serve-abort* (so +;; the next stream/tool callback raises and unwinds the HTTP request) +;; AND bumps *turn-gen* (so the worker's events get dropped at emit time +;; — this matters when the model is in a non-streaming "thinking" phase +;; with no callbacks firing). The UI gets `cancelled` + `turn_end` +;; immediately and the orphaned worker dies silently whenever it +;; eventually finishes. + +(def *serve-abort* (cons #f #f)) +(def *turn-gen* 0) ;; ── session state ────────────────────────────────────────────────────── @@ -125,35 +130,190 @@ (def (ensure-session!) (unless *current-session* (let ((s (session-create "Serve session"))) - (set! *current-session* (session-id s)))) + (set! *current-session* (session-id s)) + (save-last-session-id! (session-id s)))) *current-session*) +;; ── persistent "last session" pointer ────────────────────────────────── +;; A single text file in ~/.jcode names the most recent session id used by +;; this server. On a fresh TCP connection we try to resume that session so +;; the APK gets its history back even though it doesn't store anything +;; locally itself. + +(def (last-session-path) + (path-join (or (getenv "HOME") ".") ".jcode" "last-session")) + +(def (save-last-session-id! sid) + (try + (begin + (ensure-jcode-dir!) + (write-file-string (last-session-path) sid)) + (catch (_) (void)))) + +(def (load-last-session-id) + (let ((p (last-session-path))) + (and (file-exists? p) + (try (string-trim (read-file-string p)) + (catch (_) #f))))) + +;; ── history replay ───────────────────────────────────────────────────── +;; After `ready`, walk the stored messages for the active session and +;; emit one event per renderable item so the client can repaint its chat +;; view. `history_end` marks completion. + +(def (emit-history sid) + (let ((msgs (try (session-get-messages sid) (catch (_) '())))) + (for-each emit-history-message msgs) + (emit-event "history_end" '()))) + +(def (emit-history-message msg) + (let ((role (message-role msg)) + (content (message-content msg)) + (tcs (message-tool-calls msg))) + (cond + ((equal? role "user") + (when (and content (not (equal? content ""))) + (emit-event "history" + `(("role" . "user") ("content" . ,content))))) + ((equal? role "assistant") + (when (and content (not (equal? content ""))) + (emit-event "history" + `(("role" . "assistant") ("content" . ,content)))) + (when (pair? tcs) + (for-each + (lambda (tc) + (let ((args (tool-call-arguments tc))) + (emit-event "history_tool" + `(("name" . ,(tool-call-name tc)) + ("args" . ,(if (string? args) args + (json-object->string args))))))) + tcs)))))) + +;; ── callbacks wired into agent-run (gen-aware) ──────────────────────── + +(def (cb-aborted? gen) + (or (car *serve-abort*) + (not (= gen *turn-gen*)))) + +(def (make-stream-cb gen) + (lambda (token) + (when (cb-aborted? gen) + (error 'stream-aborted "cancelled by client")) + (emit-event "token" `(("text" . ,token))))) + +(def (make-tool-cb gen) + (lambda (phase name args) + (when (and (eq? phase 'start) (cb-aborted? gen)) + (error 'stream-aborted "cancelled by client")) + ;; Drop emissions from a stale generation (orphaned worker after cancel). + (when (= gen *turn-gen*) + (cond + ((eq? phase 'start) + (emit-event "tool_start" + `(("name" . ,name) + ("args" . ,args)))) + ((eq? phase 'end) + (emit-event "tool_end" `(("name" . ,name)))))))) + +(def (make-usage-cb gen) + (lambda (usage) + (when (= gen *turn-gen*) + (let ((tin 0) (tout 0) (cost 0.0)) + (for-each + (lambda (pair) + (case (car pair) + ((tokens-in) (set! tin (cdr pair))) + ((tokens-out) (set! tout (cdr pair))) + ((cost) (set! cost (cdr pair))))) + usage) + (set! *session-tokens-in* (+ *session-tokens-in* tin)) + (set! *session-tokens-out* (+ *session-tokens-out* tout)) + (set! *session-cost* (+ *session-cost* cost)) + (emit-event "usage" + `(("tokens_in" . ,*session-tokens-in*) + ("tokens_out" . ,*session-tokens-out*) + ("cost" . ,*session-cost*))))))) + ;; ── event handlers ───────────────────────────────────────────────────── +;; Tracks the worker thread for the in-flight turn (or #f when idle). +;; A second "user" event arriving while a turn is still running is +;; rejected rather than silently spawning a concurrent worker that +;; would corrupt streaming output. +(def *turn-worker* #f) + +(def (turn-busy?) + (and *turn-worker* (not (thread-done? *turn-worker*)))) + +(def (run-turn-worker sid text mode gen) + (try + (with-mode mode + (lambda () + (parameterize ((current-stream-cb (make-stream-cb gen)) + (current-tool-cb (make-tool-cb gen)) + (current-usage-cb (make-usage-cb gen))) + (agent-run sid text)))) + (when (= gen *turn-gen*) + (emit-event "turn_end" `(("session_id" . ,sid)))) + (catch (e) + (let ((msg (err->string e))) + ;; Cancelling between assistant-with-tool_calls and tool-result + ;; appends leaves the history in a state the API rejects on the + ;; next turn. Repair it now so the next turn isn't poisoned. + (try (session-repair-orphan-tool-calls! sid) (catch (_) (void))) + (cond + ;; Cancelled: handle-cancel already emitted cancelled+turn_end. + ;; Stay silent so the orphaned worker doesn't double-emit. + ((string-contains msg "stream-aborted") (void)) + ;; Stale generation: same — we were orphaned, don't say anything. + ((not (= gen *turn-gen*)) (void)) + (else + (log-error logger "turn-error" `((msg . ,msg))) + (emit-error msg) + (emit-event "turn_end" `(("session_id" . ,sid))))))))) + (def (handle-user text mode) - (let ((sid (ensure-session!))) - (try - (with-mode mode - (lambda () - (parameterize ((current-stream-cb serve-stream-cb) - (current-tool-cb serve-tool-cb) - (current-usage-cb serve-usage-cb)) - (agent-run sid text)))) - (emit-event "turn_end" `(("session_id" . ,sid))) - (catch (e) - (log-error logger "turn-error" `((msg . ,(err->string e)))) - (emit-error (err->string e)) - (emit-event "turn_end" `(("session_id" . ,sid))))))) + (cond + ((turn-busy?) + (emit-error "turn already in progress; send {\"type\":\"cancel\"} first")) + (else + (let ((sid (ensure-session!)) + (gen (+ 1 *turn-gen*))) + (set! *turn-gen* gen) + (set-car! *serve-abort* #f) + (set! *turn-worker* + (spawn (lambda () (run-turn-worker sid text mode gen)))))))) + +(def (handle-cancel) + (let ((sid (or *current-session* ""))) + (cond + ((turn-busy?) + ;; Set abort + bump gen. The worker is orphaned: even if it's blocked + ;; in HTTP with no callbacks firing, we ack the client immediately + ;; and drop any future events from the orphaned generation. The + ;; worker eventually unblocks (next streaming chunk OR HTTP timeout) + ;; and exits silently because its callbacks no longer match *turn-gen*. + (set-car! *serve-abort* #t) + (set! *turn-gen* (+ 1 *turn-gen*)) + (set! *turn-worker* #f) + (log-info logger "cancel" '((msg . "abort flag set, worker orphaned"))) + (emit-event "cancelled" '()) + (emit-event "turn_end" `(("session_id" . ,sid)))) + (else + ;; No turn in flight — ack quietly so the client UI can reset state. + (emit-event "cancelled" '()))))) (def (handle-new-session title) (let ((s (session-create (or title "New session")))) (set! *current-session* (session-id s)) + (save-last-session-id! (session-id s)) (set! *session-tokens-in* 0) (set! *session-tokens-out* 0) (set! *session-cost* 0.0) (emit-event "ready" `(("session_id" . ,(session-id s)) - ("session_title" . ,(session-title s)))))) + ("session_title" . ,(session-title s)))) + (emit-history (session-id s)))) (def (handle-switch-session id) (let ((s (session-load id))) @@ -162,9 +322,11 @@ (emit-error (format "session not found: ~a" id))) (else (set! *current-session* (session-id s)) + (save-last-session-id! (session-id s)) (emit-event "ready" `(("session_id" . ,(session-id s)) - ("session_title" . ,(session-title s)))))))) + ("session_title" . ,(session-title s)))) + (emit-history (session-id s)))))) (def (handle-list-sessions) (for-each @@ -266,6 +428,8 @@ (emit-error "set_model missing 'name'")))) ((equal? type "get_status") (handle-get-status)) + ((equal? type "cancel") + (handle-cancel)) ((equal? type "ping") (emit-event "pong" '())) (else @@ -274,12 +438,15 @@ ;; ── JSONL event loop (shared between stdio and TCP) ─────────────────── (def (serve-loop) - (let ((sid (ensure-session!))) + (let* ((sid (ensure-session!)) + (s (session-load sid)) + (title (if s (session-title s) "Serve session"))) (emit-event "ready" `(("session_id" . ,sid) - ("session_title" . "Serve session"))) + ("session_title" . ,title))) (handle-get-config) - (handle-get-status)) + (handle-get-status) + (emit-history sid)) (let loop () (let ((line (get-line (*serve-in*)))) (cond @@ -482,8 +649,13 @@ (try (parameterize ((*serve-in* in) (*serve-out* out)) - ;; Reset session for new client connection - (set! *current-session* #f) + ;; New client connection: try to resume the last active session + ;; (preserved across server restarts via ~/.jcode/last-session). + ;; If the file is missing or points to a deleted session, fall + ;; through and ensure-session! will create a fresh one. + (let ((last (load-last-session-id))) + (set! *current-session* + (and last (session-load last) last))) (set! *session-tokens-in* 0) (set! *session-tokens-out* 0) (set! *session-cost* 0.0) @@ -581,7 +753,15 @@ (name . ,name))) (relay-host-loop relay-addr relay-port name relay-token server-token)))) (port-opt - (let ((bind-addr (if bind-opt (cdr bind-opt) "127.0.0.1"))) + ;; TCP mode — auto-redirect stderr to ./jcode.log so the user + ;; doesn't have to remember `2>> jcode.log` on every restart. + (let* ((log-path "jcode.log") + (log-port (try (open-output-file log-path '(append)) + (catch (_) (open-output-file log-path)))) + (bind-addr (if bind-opt (cdr bind-opt) "127.0.0.1"))) + (fprintf (current-error-port) "[INFO] logs → ~a~n" log-path) + (flush-output-port (current-error-port)) + (current-error-port log-port) (log-info logger "serve-start" `((mode . "tcp") (bind . ,bind-addr) (port . ,(cdr port-opt)))) (tcp-serve-loop bind-addr (cdr port-opt))))