diff --git a/CLAUDE.md b/CLAUDE.md
index 05ec3ca..e849251 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,7 +70,8 @@ package format · 0004 compiling procedure bodies · 0005 mutable pairs · 0006
as abnormal passes · 0007 identity for derived combiners · 0008 caching compiled
bodies (proposed, deliberately not built) · 0009 editor tooling from the runtime
outward (`ik check --json`, environment enumeration, the `ik lsp` language
-server, reader recovery, and the environment/profile views are its phases 1–5).
+server, reader recovery, the environment/profile views, and the `ik session`
+protocol are its phases 1–6; the debug adapter itself stays proposed).
They record measurements and rejected options, not just decisions. When a prediction
in one turns out wrong, correct it in place rather than leaving it — several carry
diff --git a/IronKernel.Tests/IronKernel.Tests.fsproj b/IronKernel.Tests/IronKernel.Tests.fsproj
index de5de79..9a1c988 100644
--- a/IronKernel.Tests/IronKernel.Tests.fsproj
+++ b/IronKernel.Tests/IronKernel.Tests.fsproj
@@ -28,6 +28,7 @@
+
diff --git a/IronKernel.Tests/SessionTests.fs b/IronKernel.Tests/SessionTests.fs
new file mode 100644
index 0000000..63f5357
--- /dev/null
+++ b/IronKernel.Tests/SessionTests.fs
@@ -0,0 +1,114 @@
+module IronKernel.Tests.SessionTests
+
+open System.IO
+open System.Text
+open System.Text.Json
+open Xunit
+
+open IronKernel.Ast
+open IronKernel.Session
+
+let private frame (stream: Stream) (json: string) =
+ let bytes = Encoding.UTF8.GetBytes json
+ let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
+ stream.Write(header, 0, header.Length)
+ stream.Write(bytes, 0, bytes.Length)
+
+let private readFrames (data: byte[]) =
+ let text = Encoding.UTF8.GetString data
+ let mutable index = 0
+ let frames = ResizeArray()
+ while index < text.Length do
+ let headerEnd = text.IndexOf("\r\n\r\n", index, System.StringComparison.Ordinal)
+ let length =
+ text.Substring(index, headerEnd - index).Split("\r\n")
+ |> Array.pick (fun line ->
+ if line.StartsWith("Content-Length:", System.StringComparison.OrdinalIgnoreCase) then
+ Some(int (line.Substring("Content-Length:".Length).Trim()))
+ else None)
+ let bodyStart = Encoding.UTF8.GetByteCount(text.Substring(0, headerEnd)) + 4
+ frames.Add(JsonDocument.Parse(Encoding.UTF8.GetString(data, bodyStart, length)))
+ index <- (Encoding.UTF8.GetString(data, 0, bodyStart + length)).Length
+ List.ofSeq frames
+
+let private session profile (messages: string list) =
+ use input = new MemoryStream()
+ messages |> List.iter (frame input)
+ input.Position <- 0L
+ use output = new MemoryStream()
+ let exitCode = runOn input output profile
+ Assert.Equal(0, exitCode)
+ readFrames (output.ToArray())
+
+let private request id method' parameters =
+ sprintf """{"jsonrpc":"2.0","id":%d,"method":"%s","params":%s}""" (id: int) (method': string) (parameters: string)
+
+let private evalRequest id (code: string) =
+ request id "eval" (sprintf """{"code":%s}""" (JsonSerializer.Serialize code))
+
+let private resultOf id (frames: JsonDocument list) =
+ frames
+ |> List.pick (fun frameDocument ->
+ let root = frameDocument.RootElement
+ match root.TryGetProperty "id" with
+ | true, value when value.ValueKind = JsonValueKind.Number && value.GetInt32() = id ->
+ match root.TryGetProperty "result" with
+ | true, result -> Some result
+ | _ -> Some(root.GetProperty "error")
+ | _ -> None)
+
+[]
+let ``a session evaluates persistently and separates errors from values`` () =
+ let frames =
+ session Unrestricted
+ [ request 1 "initialize" "{}"
+ evalRequest 2 "(define x 40)\n(+ x 2)"
+ evalRequest 3 "x"
+ evalRequest 4 "(car 5)"
+ request 5 "reset" "{}"
+ evalRequest 6 "x" ]
+ Assert.Equal("unrestricted", (resultOf 1 frames).GetProperty("profile").GetString())
+
+ // A multi-form eval returns the last value; the define persists.
+ Assert.Equal("42", (resultOf 2 frames).GetProperty("value").GetString())
+ Assert.Equal("40", (resultOf 3 frames).GetProperty("value").GetString())
+
+ // The phase-6 point: an error is a structured field, not stdout text.
+ let error = (resultOf 4 frames).GetProperty "error"
+ Assert.Contains("expected pair", error.GetProperty("message").GetString())
+ let location = error.GetProperty("locations").[0]
+ Assert.Equal("session", location.GetProperty("file").GetString())
+ Assert.Equal(1, location.GetProperty("range").GetProperty("start").GetProperty("line").GetInt32())
+ let mutable value = Unchecked.defaultof
+ Assert.False((resultOf 4 frames).TryGetProperty("value", &value))
+
+ // Reset restores the bootstrap: the definition is gone.
+ let afterReset = resultOf 6 frames
+ Assert.Contains("unbound variable", afterReset.GetProperty("error").GetProperty("message").GetString())
+
+[]
+let ``printed output travels in the response not the channel`` () =
+ let frames =
+ session Unrestricted
+ [ evalRequest 1 "(print \"hello, session\")" ]
+ let result = resultOf 1 frames
+ Assert.Equal("hello, session", result.GetProperty("output").GetString())
+ Assert.True(result.GetProperty("inert").GetBoolean())
+
+[]
+let ``the session honours the capability profile`` () =
+ let frames =
+ session Minimal
+ [ request 1 "initialize" "{}"
+ evalRequest 2 "(print \"denied\")" ]
+ Assert.Equal("minimal", (resultOf 1 frames).GetProperty("profile").GetString())
+ // Under minimal there is no print binding at all: host output is authority.
+ let error = (resultOf 2 frames).GetProperty "error"
+ Assert.Contains("print", error.GetProperty("message").GetString())
+
+[]
+let ``unknown session requests get a method-not-found error`` () =
+ let frames =
+ session Unrestricted [ request 1 "step" "{}" ]
+ let root = frames.Head.RootElement
+ Assert.Equal(-32601, root.GetProperty("error").GetProperty("code").GetInt32())
diff --git a/IronKernel/IronKernel.fsproj b/IronKernel/IronKernel.fsproj
index a7c79ab..ab2ce5b 100644
--- a/IronKernel/IronKernel.fsproj
+++ b/IronKernel/IronKernel.fsproj
@@ -44,6 +44,8 @@
+
+
diff --git a/IronKernel/Jsonrpc.fs b/IronKernel/Jsonrpc.fs
new file mode 100644
index 0000000..e3e9702
--- /dev/null
+++ b/IronKernel/Jsonrpc.fs
@@ -0,0 +1,111 @@
+namespace IronKernel
+
+/// Content-Length framed JSON-RPC over streams: the transport the language
+/// server (`ik lsp`) and the session protocol (`ik session`) share. A
+/// deliberately small hand-rolled layer -- framing, requests, responses,
+/// notifications -- kept dependency-free and testable against in-memory
+/// streams (ADR 0009 phases 3 and 6).
+module Jsonrpc =
+
+ open System
+ open System.IO
+ open System.Text
+ open System.Text.Json
+
+ let readFramed (input: Stream) : JsonDocument option =
+ let readLine () =
+ let builder = StringBuilder()
+ let mutable eof = false
+ let mutable finished = false
+ while not finished do
+ match input.ReadByte() with
+ | -1 ->
+ eof <- true
+ finished <- true
+ | 10 -> finished <- true
+ | 13 -> ()
+ | b -> builder.Append(char b) |> ignore
+ if eof && builder.Length = 0 then None else Some(builder.ToString())
+ let mutable contentLength = -1
+ let rec readHeaders () =
+ match readLine () with
+ | None -> false
+ | Some "" -> true
+ | Some line ->
+ if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then
+ match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with
+ | true, length -> contentLength <- length
+ | _ -> ()
+ readHeaders ()
+ if not (readHeaders ()) || contentLength < 0 then None
+ else
+ let buffer = Array.zeroCreate contentLength
+ let mutable filled = 0
+ let mutable ok = true
+ while ok && filled < contentLength do
+ let count = input.Read(buffer, filled, contentLength - filled)
+ if count <= 0 then ok <- false else filled <- filled + count
+ if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None
+
+ let writeFramed (output: Stream) (json: string) =
+ let bytes = Encoding.UTF8.GetBytes json
+ let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
+ output.Write(header, 0, header.Length)
+ output.Write(bytes, 0, bytes.Length)
+ output.Flush()
+
+ let toJson (write: Utf8JsonWriter -> unit) =
+ use stream = new MemoryStream()
+ use writer = new Utf8JsonWriter(stream)
+ write writer
+ writer.Flush()
+ Encoding.UTF8.GetString(stream.ToArray())
+
+ let respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) =
+ toJson (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteString("jsonrpc", "2.0")
+ writer.WritePropertyName "id"
+ id.WriteTo writer
+ writer.WritePropertyName "result"
+ writeResult writer
+ writer.WriteEndObject())
+ |> writeFramed output
+
+ let respondError output (id: JsonElement) code (message: string) =
+ toJson (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteString("jsonrpc", "2.0")
+ writer.WritePropertyName "id"
+ id.WriteTo writer
+ writer.WritePropertyName "error"
+ writer.WriteStartObject()
+ writer.WriteNumber("code", (code: int))
+ writer.WriteString("message", message)
+ writer.WriteEndObject()
+ writer.WriteEndObject())
+ |> writeFramed output
+
+ let notify output (method: string) (writeParams: Utf8JsonWriter -> unit) =
+ toJson (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteString("jsonrpc", "2.0")
+ writer.WriteString("method", method)
+ writer.WritePropertyName "params"
+ writeParams writer
+ writer.WriteEndObject())
+ |> writeFramed output
+
+ /// The method name, id (when present), and params of one message.
+ let envelope (document: JsonDocument) =
+ let root = document.RootElement
+ let methodName =
+ match root.TryGetProperty "method" with
+ | true, value -> value.GetString()
+ | _ -> ""
+ let hasId, id = root.TryGetProperty "id"
+ let parameters =
+ match root.TryGetProperty "params" with
+ | true, value -> value
+ | _ -> JsonDocument.Parse("null").RootElement
+ methodName, hasId, id, parameters
diff --git a/IronKernel/LanguageServer.fs b/IronKernel/LanguageServer.fs
index 263bbb0..9ede056 100644
--- a/IronKernel/LanguageServer.fs
+++ b/IronKernel/LanguageServer.fs
@@ -18,92 +18,7 @@ module LanguageServer =
open System.Text.Json
open Ast
open Errors
-
- // ---- Framing ----------------------------------------------------------
-
- let private readFramed (input: Stream) : JsonDocument option =
- let readLine () =
- let builder = StringBuilder()
- let mutable eof = false
- let mutable finished = false
- while not finished do
- match input.ReadByte() with
- | -1 ->
- eof <- true
- finished <- true
- | 10 -> finished <- true
- | 13 -> ()
- | b -> builder.Append(char b) |> ignore
- if eof && builder.Length = 0 then None else Some(builder.ToString())
- let mutable contentLength = -1
- let rec readHeaders () =
- match readLine () with
- | None -> false
- | Some "" -> true
- | Some line ->
- if line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) then
- match Int32.TryParse(line.Substring("Content-Length:".Length).Trim()) with
- | true, length -> contentLength <- length
- | _ -> ()
- readHeaders ()
- if not (readHeaders ()) || contentLength < 0 then None
- else
- let buffer = Array.zeroCreate contentLength
- let mutable filled = 0
- let mutable ok = true
- while ok && filled < contentLength do
- let count = input.Read(buffer, filled, contentLength - filled)
- if count <= 0 then ok <- false else filled <- filled + count
- if ok then Some(JsonDocument.Parse(ReadOnlyMemory buffer)) else None
-
- let private writeFramed (output: Stream) (json: string) =
- let bytes = Encoding.UTF8.GetBytes json
- let header = Encoding.ASCII.GetBytes(sprintf "Content-Length: %d\r\n\r\n" bytes.Length)
- output.Write(header, 0, header.Length)
- output.Write(bytes, 0, bytes.Length)
- output.Flush()
-
- let private toJson (write: Utf8JsonWriter -> unit) =
- use stream = new MemoryStream()
- use writer = new Utf8JsonWriter(stream)
- write writer
- writer.Flush()
- Encoding.UTF8.GetString(stream.ToArray())
-
- let private respond output (id: JsonElement) (writeResult: Utf8JsonWriter -> unit) =
- toJson (fun writer ->
- writer.WriteStartObject()
- writer.WriteString("jsonrpc", "2.0")
- writer.WritePropertyName "id"
- id.WriteTo writer
- writer.WritePropertyName "result"
- writeResult writer
- writer.WriteEndObject())
- |> writeFramed output
-
- let private respondError output (id: JsonElement) code (message: string) =
- toJson (fun writer ->
- writer.WriteStartObject()
- writer.WriteString("jsonrpc", "2.0")
- writer.WritePropertyName "id"
- id.WriteTo writer
- writer.WritePropertyName "error"
- writer.WriteStartObject()
- writer.WriteNumber("code", (code: int))
- writer.WriteString("message", message)
- writer.WriteEndObject()
- writer.WriteEndObject())
- |> writeFramed output
-
- let private notify output (method: string) (writeParams: Utf8JsonWriter -> unit) =
- toJson (fun writer ->
- writer.WriteStartObject()
- writer.WriteString("jsonrpc", "2.0")
- writer.WriteString("method", method)
- writer.WritePropertyName "params"
- writeParams writer
- writer.WriteEndObject())
- |> writeFramed output
+ open Jsonrpc
// ---- Positions --------------------------------------------------------
@@ -526,16 +441,7 @@ module LanguageServer =
| None -> running <- false
| Some document ->
use document = document
- let root = document.RootElement
- let methodName =
- match root.TryGetProperty "method" with
- | true, value -> value.GetString()
- | _ -> ""
- let hasId, id = root.TryGetProperty "id"
- let parameters =
- match root.TryGetProperty "params" with
- | true, value -> value
- | _ -> JsonDocument.Parse("null").RootElement
+ let methodName, hasId, id, parameters = envelope document
match methodName with
| "initialize" ->
respond output id (fun writer ->
diff --git a/IronKernel/Program.fs b/IronKernel/Program.fs
index a232dc9..35aaf06 100644
--- a/IronKernel/Program.fs
+++ b/IronKernel/Program.fs
@@ -16,6 +16,7 @@ let private usage =
ironkernel [--profile ] run [args...] Run a .ikr script or .ikc package
ironkernel [--profile ] check [--json] [ | project.ikproj]
ironkernel [--profile ] lsp Start the language server (stdio)
+ ironkernel [--profile ] session Start an evaluation session (stdio)
ironkernel [--profile ] compile [-o ]
ironkernel [--profile ] compile --managed [-o ]
ironkernel --profile compile --native [-o ]
@@ -162,6 +163,7 @@ let private dispatch (profileOverride: CapabilityProfile option) args =
// Non-source tokens (including flags) are project program args, not scripts.
withProject profileOverride None (fun project -> ProjectTool.run project scriptArgs)
| ["lsp"] -> IronKernel.LanguageServer.run profile
+ | ["session"] -> IronKernel.Session.run profile
| "check" :: rest ->
let json = List.contains "--json" rest
let arguments = rest |> List.filter (fun argument -> argument <> "--json")
diff --git a/IronKernel/Session.fs b/IronKernel/Session.fs
new file mode 100644
index 0000000..7cf4085
--- /dev/null
+++ b/IronKernel/Session.fs
@@ -0,0 +1,130 @@
+namespace IronKernel
+
+/// `ik session`: a persistent evaluation session over Content-Length framed
+/// JSON-RPC on stdio -- the protocol ADR 0009 phase 6 puts underneath
+/// everything interactive: editor eval, the inspector's remote-eval, and
+/// eventually the debug adapter. The point phase 6 opens with: results and
+/// errors are separate, structured values on the channel, which the human
+/// REPL never offered -- it folds errors into stdout as `error : ...` text.
+///
+/// One session is one client. Evaluation runs on the loop, so a
+/// non-terminating program blocks the session; the client owns the timeout
+/// and restarts the process, which is also the interrupt story until the
+/// trampoline learns cancellation.
+module Session =
+
+ open System
+ open System.IO
+ open System.Text.Json
+ open Ast
+ open Errors
+ open Jsonrpc
+
+ let private profileName = function
+ | Minimal -> "minimal"
+ | Safe -> "safe"
+ | Unrestricted -> "unrestricted"
+
+ let private writePosition (writer: Utf8JsonWriter) (position: SourcePosition) =
+ writer.WriteStartObject()
+ writer.WriteNumber("line", position.line)
+ writer.WriteNumber("column", position.column)
+ writer.WriteEndObject()
+
+ /// Locations use the same 1-based, end-exclusive shape as `ik check --json`.
+ let private writeLocations (writer: Utf8JsonWriter) locations =
+ writer.WritePropertyName "locations"
+ writer.WriteStartArray()
+ for span, _ in locations do
+ writer.WriteStartObject()
+ writer.WriteString("file", (span: SourceSpan).sourceName)
+ writer.WritePropertyName "range"
+ writer.WriteStartObject()
+ writer.WritePropertyName "start"
+ writePosition writer span.startPosition
+ writer.WritePropertyName "end"
+ writePosition writer span.endPosition
+ writer.WriteEndObject()
+ writer.WriteEndObject()
+ writer.WriteEndArray()
+
+ /// Evaluate every form in `code`, capturing what the program prints so it
+ /// travels in the response instead of corrupting the framed channel.
+ /// Raw standard-output ports still bypass the capture -- a limitation the
+ /// debug adapter's stdio discipline will have to close.
+ let private evaluate env (code: string) =
+ let previousOut = Console.Out
+ use captured = new StringWriter()
+ Console.SetOut captured
+ let outcome =
+ try
+ try
+ Emit.runSource env "session" code
+ with ex ->
+ Choice1Of2(ClrException ex)
+ finally
+ Console.SetOut previousOut
+ outcome, captured.ToString()
+
+ let runOn (input: Stream) (output: Stream) profile : int =
+ match Emit.bootstrapEnvForProfile profile with
+ | Choice1Of2 error ->
+ eprintfn "Startup error: %s" (showError error)
+ 1
+ | Choice2Of2 initialEnv ->
+ let mutable env = initialEnv
+ let mutable running = true
+ while running do
+ match readFramed input with
+ | None -> running <- false
+ | Some document ->
+ use document = document
+ let methodName, hasId, id, parameters = envelope document
+ match methodName with
+ | "initialize" ->
+ respond output id (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteString("name", "IronKernel")
+ writer.WriteString("version", Repl.version)
+ writer.WriteString("profile", profileName profile)
+ writer.WriteEndObject())
+ | "eval" ->
+ let code = parameters.GetProperty("code").GetString()
+ let outcome, printed = evaluate env code
+ respond output id (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteString("output", printed)
+ (match outcome with
+ | Choice2Of2 value ->
+ let inert =
+ match value with
+ | Inert -> true
+ | _ -> false
+ writer.WriteBoolean("inert", inert)
+ writer.WriteString("value", showVal value)
+ | Choice1Of2 error ->
+ let locations, core = errorLocations error
+ writer.WritePropertyName "error"
+ writer.WriteStartObject()
+ writer.WriteString("message", errorMessage core)
+ writer.WriteString("rendered", showError error)
+ writeLocations writer locations
+ writer.WriteEndObject())
+ writer.WriteEndObject())
+ | "reset" ->
+ match Emit.bootstrapEnvForProfile profile with
+ | Choice2Of2 fresh ->
+ env <- fresh
+ respond output id (fun writer ->
+ writer.WriteStartObject()
+ writer.WriteEndObject())
+ | Choice1Of2 error ->
+ respondError output id -32000 (showError error)
+ | "exit" -> running <- false
+ | other when hasId ->
+ respondError output id -32601 (sprintf "method not found: %s" other)
+ | _ -> ()
+ 0
+
+ let run profile : int =
+ runOn (Console.OpenStandardInput()) (Console.OpenStandardOutput()) profile
diff --git a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md
index 027ef33..8347044 100644
--- a/docs/adr/0009-editor-tooling-from-the-runtime-outward.md
+++ b/docs/adr/0009-editor-tooling-from-the-runtime-outward.md
@@ -1,7 +1,7 @@
# ADR 0009: Editor tooling grows from the runtime outward
-Status: Accepted — phases 1 through 5 implemented (phase 5's third item
-deferred behind phase 6)
+Status: Accepted — phases 1 through 5 implemented, and phase 6's session
+protocol; the debug adapter itself is proposed, deliberately not built yet
## Decision
@@ -212,10 +212,40 @@ phase 6's session protocol. Deferred with that dependency named, so the
"nearly free" framing does not get re-derived from the `CLocated` line
above.
-**Phase 6 — debug adapter.** Requires a persistent session protocol that does
-not exist — the REPL cannot even separate errors from values today — so the
-protocol is the prerequisite, and it should serve the inspector and
-remote-eval too, not just the DAP.
+**Phase 6 — the session protocol; the debug adapter stays proposed.** *The
+protocol is done; the adapter is deliberately not built yet, in the sense of
+ADR 0008.*
+
+`ik session` is a persistent evaluation session over the same framed
+JSON-RPC transport as `ik lsp` — the framing layer is now a shared
+`Jsonrpc` module rather than a second copy. `eval` returns what the human
+REPL never separated: the value (with an inert flag), everything the
+program printed (captured so it travels in the response instead of
+corrupting the channel), or a structured error — message, rendered
+diagnostic, and spans in the same 1-based shape as `ik check --json`.
+Definitions persist across evals; `reset` re-bootstraps; the capability
+profile is honored (`--profile minimal` has no `print` to deny). The
+interrupt story is owned by the client: a hung evaluation cannot be
+cancelled, so the timeout kills the process and the next eval starts
+fresh — honest, and sufficient until the trampoline learns cancellation.
+One known leak: raw standard-output ports write past the `Console`
+capture; the adapter's stdio discipline will have to close that.
+
+In the editor: **Eval Selection in Session** (persistent state, results
+and captured output in the output channel, structured errors rendered
+with their carets) and **Restart Session**; changing the profile restarts
+both servers so displayed authority stays actual authority.
+
+Two scope notes. The inspector's "eval in this env" still has no
+per-frame form — the session evaluates in its own ground environment, and
+addressing an arbitrary frame needs an environment-handle op on the
+protocol, which is the protocol's natural next verb. And the debug
+adapter remains unbuilt because the protocol was never its hard part:
+stepping requires cancellation and suspension points in the CPS
+trampoline, breakpoints need the `CLocated` span map wired to those
+points, and the coverage view phase 5 deferred wants the same runtime
+instrumentation. Those are one design, and it should be taken whole
+rather than bolted on verb by verb.
## Alternatives
diff --git a/editors/vscode/README.md b/editors/vscode/README.md
index 470eebd..3f0aabb 100644
--- a/editors/vscode/README.md
+++ b/editors/vscode/README.md
@@ -21,6 +21,9 @@ The extension never implements a second evaluator in JavaScript.
- **Profile status bar**: the effective capability profile at a glance,
flagging when it overrides the project's own ``; click
to switch (the language server restarts to match)
+- **Eval Selection in Session**: a persistent `ik session` process with
+ results, captured output, and structured errors in the output channel;
+ state survives between evals, a hung eval restarts the session
- **Run Current File** and **Compile Current File to IKC** commands
- **Run Project** and **Build Project** for `.ikproj` (nearest project, picker, or explorer context menu)
- Check on save: `ik check --json` runs on saved `.ikr` files and publishes
@@ -73,6 +76,7 @@ host authority available to editor commands and playground runs.
- `IronKernel: Compile Current File to IKC`
- `IronKernel: Run Project` — `run ` (plus `ironkernel.runArgs`)
- `IronKernel: Build Project` — `build ` → `bin/*.ikc`
+- `IronKernel: Eval Selection in Session` / `IronKernel: Restart Session`
- `IronKernel: Refresh Environment View`
- `IronKernel: Select Capability Profile`
- `IronKernel: Open Playground`
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index e4f7625..b828689 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -39,7 +39,9 @@
"onCommand:ironkernel.openPlayground",
"onCommand:ironkernel.refreshEnvironment",
"onCommand:ironkernel.selectProfile",
- "onView:ironkernelEnvironment"
+ "onView:ironkernelEnvironment",
+ "onCommand:ironkernel.evalSelection",
+ "onCommand:ironkernel.restartSession"
],
"capabilities": {
"untrustedWorkspaces": {
@@ -136,6 +138,16 @@
"command": "ironkernel.selectProfile",
"title": "IronKernel: Select Capability Profile",
"category": "IronKernel"
+ },
+ {
+ "command": "ironkernel.evalSelection",
+ "title": "IronKernel: Eval Selection in Session",
+ "category": "IronKernel"
+ },
+ {
+ "command": "ironkernel.restartSession",
+ "title": "IronKernel: Restart Session",
+ "category": "IronKernel"
}
],
"menus": {
@@ -168,6 +180,11 @@
"command": "ironkernel.buildProject",
"when": "resourceExtname == .ikproj && isWorkspaceTrusted",
"group": "ironkernel@2"
+ },
+ {
+ "command": "ironkernel.evalSelection",
+ "when": "editorLangId == ironkernel && isWorkspaceTrusted",
+ "group": "ironkernel@3"
}
],
"view/title": [
@@ -260,6 +277,13 @@
"minimum": 4096,
"maximum": 16777216,
"description": "Maximum stdout or stderr retained for one execution."
+ },
+ "ironkernel.session.timeoutMs": {
+ "type": "number",
+ "default": 10000,
+ "minimum": 1000,
+ "maximum": 600000,
+ "description": "Maximum time one session evaluation may take. A hung evaluation kills the session process (its definitions are lost) and the next eval starts fresh."
}
}
},
diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts
index b4ddc46..527fb77 100644
--- a/editors/vscode/src/extension.ts
+++ b/editors/vscode/src/extension.ts
@@ -13,6 +13,7 @@ import {
type CheckLocation
} from "./diagnostics.js";
import { EnvironmentViewProvider, type EnvironmentReport } from "./environmentView.js";
+import { SessionClient, type SessionEvalResult } from "./session.js";
import { PlaygroundPanel } from "./playgroundPanel.js";
import { ProfileStatus } from "./profileStatus.js";
import { findIkprojWalkingUp, isIkprojPath, rankIkProjects } from "./projects.js";
@@ -70,6 +71,49 @@ export function activate(context: vscode.ExtensionContext): void {
.update("profile", picked, vscode.ConfigurationTarget.Workspace);
}
}),
+ vscode.commands.registerCommand("ironkernel.evalSelection", async () => {
+ const editor = vscode.window.activeTextEditor;
+ if (!editor || editor.document.languageId !== "ironkernel") {
+ void vscode.window.showInformationMessage("Open an IronKernel source file first.");
+ return;
+ }
+ if (!(await requireTrustedWorkspace())) {
+ return;
+ }
+ const selection = editor.selection;
+ const code = selection.isEmpty
+ ? editor.document.lineAt(selection.active.line).text
+ : editor.document.getText(selection);
+ if (code.trim() === "") {
+ return;
+ }
+ const client = await obtainSession(editor.document.uri, output);
+ if (!client) {
+ return;
+ }
+ output.show(true);
+ output.appendLine(`session> ${code.trim()}`);
+ try {
+ const result: SessionEvalResult = await client.eval(code);
+ if (result.output) {
+ output.append(result.output.endsWith("\n") ? result.output : result.output + "\n");
+ }
+ if (result.error) {
+ output.appendLine(result.error.rendered);
+ } else if (!result.inert && result.value !== undefined) {
+ output.appendLine(result.value);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ output.appendLine(`[session] ${message}`);
+ void vscode.window.showWarningMessage(message);
+ }
+ }),
+ vscode.commands.registerCommand("ironkernel.restartSession", () => {
+ sessionClient?.kill();
+ sessionClient = undefined;
+ output.appendLine("[session] restarted; definitions were discarded");
+ }),
vscode.window.onDidChangeActiveTextEditor(() => {
void profileStatus.update();
void refreshEnvironment();
@@ -77,9 +121,11 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("ironkernel.profile")) {
void profileStatus.update();
- // The server was launched with the old --profile; restart it so the
- // session environment carries the authority the status bar shows.
+ // Both servers were launched with the old --profile; restart them so
+ // the authority shown is the authority actually carried.
void restartLanguageServer(output).then(afterServerChange);
+ sessionClient?.kill();
+ sessionClient = undefined;
}
}),
vscode.workspace.onDidGrantWorkspaceTrust(() => {
@@ -293,6 +339,26 @@ async function saveIronKernelDocumentsNear(projectPath: string): Promise {
let languageClient: LanguageClient | undefined;
+let sessionClient: SessionClient | undefined;
+
+async function obtainSession(
+ scopeUri: vscode.Uri,
+ output: vscode.OutputChannel
+): Promise {
+ if (sessionClient) {
+ return sessionClient;
+ }
+ const resolved = await resolveConfiguredRuntime(scopeUri, output);
+ if (!resolved) {
+ return undefined;
+ }
+ const timeout = vscode.workspace
+ .getConfiguration("ironkernel", scopeUri)
+ .get("session.timeoutMs", 10000);
+ sessionClient = new SessionClient(resolved.runtime, timeout);
+ return sessionClient;
+}
+
async function restartLanguageServer(output: vscode.OutputChannel): Promise {
const client = languageClient;
languageClient = undefined;
@@ -564,6 +630,8 @@ function publishDiagnostics(
export function deactivate(): Thenable | undefined {
// Other resources registered in ExtensionContext are disposed by VS Code.
+ sessionClient?.dispose();
+ sessionClient = undefined;
const client = languageClient;
languageClient = undefined;
return client?.stop();
diff --git a/editors/vscode/src/session.ts b/editors/vscode/src/session.ts
new file mode 100644
index 0000000..4230433
--- /dev/null
+++ b/editors/vscode/src/session.ts
@@ -0,0 +1,183 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import type { RuntimeCommand } from "./cli.js";
+
+export function encodeFrame(json: string): Buffer {
+ const body = Buffer.from(json, "utf8");
+ return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]);
+}
+
+/** Incremental Content-Length frame decoder; push chunks, get JSON bodies. */
+export class FrameDecoder {
+ private buffer = Buffer.alloc(0);
+
+ push(chunk: Buffer): string[] {
+ this.buffer = Buffer.concat([this.buffer, chunk]);
+ const bodies: string[] = [];
+ for (;;) {
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
+ if (headerEnd < 0) {
+ break;
+ }
+ const headers = this.buffer.subarray(0, headerEnd).toString("ascii");
+ const match = /content-length:\s*(\d+)/i.exec(headers);
+ if (!match) {
+ // Unframeable garbage; drop the broken header and resynchronize.
+ this.buffer = this.buffer.subarray(headerEnd + 4);
+ continue;
+ }
+ const length = Number.parseInt(match[1] ?? "0", 10);
+ const frameEnd = headerEnd + 4 + length;
+ if (this.buffer.length < frameEnd) {
+ break;
+ }
+ bodies.push(this.buffer.subarray(headerEnd + 4, frameEnd).toString("utf8"));
+ this.buffer = this.buffer.subarray(frameEnd);
+ }
+ return bodies;
+ }
+}
+
+export interface SessionErrorLocation {
+ file: string;
+ range: { start: { line: number; column: number }; end: { line: number; column: number } };
+}
+
+export interface SessionEvalResult {
+ output: string;
+ value?: string;
+ inert?: boolean;
+ error?: { message: string; rendered: string; locations: SessionErrorLocation[] };
+}
+
+interface PendingRequest {
+ resolve: (value: unknown) => void;
+ reject: (reason: Error) => void;
+ timer: NodeJS.Timeout;
+}
+
+/**
+ * One `ik session` child process: persistent evaluation with structured
+ * results. The client owns the timeout — a hung evaluation kills the process
+ * and the next request starts a fresh session (losing its definitions, which
+ * the caller should surface).
+ */
+export class SessionClient {
+ private child: ChildProcessWithoutNullStreams | undefined;
+ private decoder = new FrameDecoder();
+ private readonly pending = new Map();
+ private nextId = 1;
+
+ constructor(
+ private readonly runtime: RuntimeCommand,
+ private readonly requestTimeoutMs: number
+ ) {}
+
+ get running(): boolean {
+ return this.child !== undefined;
+ }
+
+ private start(): ChildProcessWithoutNullStreams {
+ const child = spawn(this.runtime.command, [...this.runtime.prefixArgs, "session"], {
+ cwd: this.runtime.cwd,
+ shell: false,
+ windowsHide: true
+ });
+ this.decoder = new FrameDecoder();
+ child.stdout.on("data", (chunk: Buffer) => {
+ for (const body of this.decoder.push(chunk)) {
+ this.dispatch(body);
+ }
+ });
+ child.on("close", () => {
+ if (this.child === child) {
+ this.child = undefined;
+ }
+ this.rejectAll(new Error("The IronKernel session ended."));
+ });
+ child.on("error", () => {
+ if (this.child === child) {
+ this.child = undefined;
+ }
+ this.rejectAll(new Error("The IronKernel session could not start."));
+ });
+ this.child = child;
+ return child;
+ }
+
+ private dispatch(body: string): void {
+ let parsed: { id?: number; result?: unknown; error?: { message?: string } };
+ try {
+ parsed = JSON.parse(body) as typeof parsed;
+ } catch {
+ return;
+ }
+ if (typeof parsed.id !== "number") {
+ return;
+ }
+ const waiting = this.pending.get(parsed.id);
+ if (!waiting) {
+ return;
+ }
+ this.pending.delete(parsed.id);
+ clearTimeout(waiting.timer);
+ if (parsed.error) {
+ waiting.reject(new Error(parsed.error.message ?? "session protocol error"));
+ } else {
+ waiting.resolve(parsed.result);
+ }
+ }
+
+ private rejectAll(reason: Error): void {
+ for (const [, waiting] of this.pending) {
+ clearTimeout(waiting.timer);
+ waiting.reject(reason);
+ }
+ this.pending.clear();
+ }
+
+ request(method: string, params: unknown): Promise {
+ const child = this.child ?? this.start();
+ const id = this.nextId;
+ this.nextId += 1;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ // A hung evaluation cannot be interrupted; killing the session is
+ // the interrupt story, and the next request begins a fresh one.
+ this.kill();
+ reject(new Error(`The session did not answer within ${this.requestTimeoutMs} ms and was restarted.`));
+ }, this.requestTimeoutMs);
+ this.pending.set(id, {
+ resolve: (value) => resolve(value as T),
+ reject,
+ timer
+ });
+ child.stdin.write(
+ encodeFrame(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
+ );
+ });
+ }
+
+ eval(code: string): Promise {
+ return this.request("eval", { code });
+ }
+
+ kill(): void {
+ const child = this.child;
+ this.child = undefined;
+ if (child) {
+ child.kill();
+ }
+ this.rejectAll(new Error("The IronKernel session was restarted."));
+ }
+
+ dispose(): void {
+ const child = this.child;
+ this.child = undefined;
+ if (child) {
+ child.stdin.write(encodeFrame(JSON.stringify({ jsonrpc: "2.0", method: "exit" })));
+ setTimeout(() => child.kill(), 500);
+ }
+ this.rejectAll(new Error("The IronKernel session was disposed."));
+ }
+}
diff --git a/editors/vscode/test/session.test.ts b/editors/vscode/test/session.test.ts
new file mode 100644
index 0000000..398e9f6
--- /dev/null
+++ b/editors/vscode/test/session.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { FrameDecoder, encodeFrame } from "../src/session.js";
+
+describe("session framing", () => {
+ it("round-trips a frame, including multibyte content", () => {
+ const decoder = new FrameDecoder();
+ const body = JSON.stringify({ value: "λϝ → 42" });
+ expect(decoder.push(encodeFrame(body))).toEqual([body]);
+ });
+
+ it("reassembles frames split across chunks and batches", () => {
+ const decoder = new FrameDecoder();
+ const first = JSON.stringify({ id: 1 });
+ const second = JSON.stringify({ id: 2, value: "ok" });
+ const stream = Buffer.concat([encodeFrame(first), encodeFrame(second)]);
+ const cut = encodeFrame(first).length - 3;
+ expect(decoder.push(stream.subarray(0, 5))).toEqual([]);
+ expect(decoder.push(stream.subarray(5, cut))).toEqual([]);
+ expect(decoder.push(stream.subarray(cut))).toEqual([first, second]);
+ });
+
+ it("resynchronizes past a garbage header", () => {
+ const decoder = new FrameDecoder();
+ const body = JSON.stringify({ ok: true });
+ const noise = Buffer.from("Warning: something\r\n\r\n", "ascii");
+ expect(decoder.push(Buffer.concat([noise, encodeFrame(body)]))).toEqual([body]);
+ });
+});