diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index fda5f151..48b1cb3c 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -40,6 +40,7 @@ Supporting rules: | --- | --- | --- | | `alphanumericDashed` | every char outside `[A-Za-z0-9]` → `-` (`/a/b_c.d` → `-a-b-c-d`) | Claude (`~/.claude/projects/`) | | `slashDashed` | only `/` → `-`; dots and spaces kept | Pi (wrapped `-…--`), Droid | +| `alphanumericDashed` (again) | same rule, independently implemented as `sanitizeCwd` | Qwen (`~/.qwen/projects/`) | | `md5(cwd)` | lowercase hex md5 of the absolute path | Kimi, Cursor | | `sha256(cwd)` | lowercase hex sha256 | Gemini (older layout); newer maps cwd→slug in `~/.gemini/projects.json` | | plain cwd | stored verbatim in metadata | OpenCode (`session.directory`), Copilot (`workspace.yaml`), Qwen sidecar | @@ -64,12 +65,18 @@ Verified 2026-07-11 against locally installed CLIs (best effort where noted): | Droid | 0.147.0 | `~/.factory/sessions//.jsonl` | Lifetime + transcript match | | OpenCode | 1.17.18 | `opencode.db` `session(id, directory, time_updated)` | **Store query (`medium`)** | | Amp | 2026-05 build | `~/.cache/amp/logs/threads/T-.log` (open FD, logged in) | **Open thread-log FD (`exact`)** | -| Qwen Code | not installed | `~/.qwen/projects//chats/*.jsonl` + `*.runtime.json` | **Pid sidecar (`exact`)**; best effort | +| Qwen Code | source@deb45ae | `~/.qwen/projects//chats/*.jsonl` + `*.runtime.json` | **Pid sidecar (`exact`)**; source-verified | Codex keeps its rollout JSONL open for the whole interactive session. Claude, Pi, Droid, and Kimi close their session files between writes; Amp only materializes local thread artifacts when logged in; OpenCode's TUI holds only the shared sqlite database (no per-session file, and by default no local server port — the API worker is in-process). +Qwen's `runtime.json` sidecar (source: `packages/core/src/utils/runtimeStatus.ts`) is written explicitly for external +observers like Prowl: created on session start, atomically swapped on `/clear`/`/resume` (same pid, new session id), +and intentionally **left behind** on quit/crash. Consumers must therefore validate the claim against the live process; +Prowl requires `schema_version == 1`, a pid match, and `started_at >= process start - 2 s`, which rejects stale claims +inherited by a reused pid. + ## Rejected channel: child-process environment variables Five CLIs inject their session id into tool child processes (Claude `CLAUDE_CODE_SESSION_ID`, Codex `CODEX_THREAD_ID`, diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift index 6a61f968..e1e2d996 100644 --- a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -47,13 +47,20 @@ nonisolated enum CopilotProcessLog { } } -/// Qwen Code writes `/.runtime.json` sidecars -/// (`{"pid": ..., "session_id": ...}`) for every live session, intended for -/// external observers. +/// Qwen Code writes `/.runtime.json` sidecars for every +/// live interactive session, explicitly for external observers (source: +/// `packages/core/src/utils/runtimeStatus.ts`). Session rotation (`/clear`, +/// `/resume`) atomically swaps the sidecar, but quit/crash leaves it behind — +/// consumers must verify the claim against the live process. A claim whose +/// `started_at` predates the process start belongs to a previous owner of a +/// reused pid. nonisolated enum QwenRuntimeStatus { + private static let schemaVersion = 1 + static func session( projectsRoot: URL, pid: pid_t, + processStartedAt: Date, fileManager: FileManager = .default ) -> AgentSession? { let projects = (try? fileManager.contentsOfDirectory(at: projectsRoot, includingPropertiesForKeys: nil)) ?? [] @@ -63,7 +70,10 @@ nonisolated enum QwenRuntimeStatus { for sidecar in sidecars where sidecar.lastPathComponent.hasSuffix(".runtime.json") { guard let data = try? Data(contentsOf: sidecar), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["schema_version"] as? Int == schemaVersion, let sidecarPID = object["pid"] as? Int, sidecarPID == Int(pid), + let startedAt = object["started_at"] as? Double, + startedAt >= processStartedAt.timeIntervalSince1970 - 2, let id = object["session_id"] as? String, !id.isEmpty else { continue } let transcript = chats.appending(path: "\(id).jsonl") diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index d46674ae..88de2af0 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -219,17 +219,26 @@ nonisolated extension AgentSessionProfile { } ) - /// Qwen Code: `~/.qwen/projects//chats/.jsonl` plus an - /// official pid→session sidecar `.runtime.json`. `QWEN_CODE_SESSION_ID` - /// on shell children. Storage layout is best-effort: not verified against a - /// local install. + /// Qwen Code: `~/.qwen/projects//chats/.jsonl` plus the + /// official pid→session sidecar `.runtime.json` next to it. The cwd + /// sanitizer is Claude's rule (`[^a-zA-Z0-9]` → `-`, `sanitizeCwd` in + /// `packages/core/src/utils/paths.ts`). Source-verified against QwenLM/ + /// qwen-code@deb45ae; not exercised against a local install. fileprivate static let qwen = AgentSessionProfile( parsePath: { uuidJSONL(path: $0, marker: "/.qwen/projects/") }, - candidateRoots: { home, _, _, _ in + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".qwen/projects")] } + return [home.appending(path: ".qwen/projects/\(alphanumericDashed(cwd.path))/chats")] + }, + fallbackRoots: { home, _ in [home.appending(path: ".qwen/projects")] }, - pidKeyedSession: { home, pid, _ in - QwenRuntimeStatus.session(projectsRoot: home.appending(path: ".qwen/projects"), pid: pid) + pidKeyedSession: { home, pid, processStartedAt in + QwenRuntimeStatus.session( + projectsRoot: home.appending(path: ".qwen/projects"), + pid: pid, + processStartedAt: processStartedAt + ) } ) } diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index e6f0d463..06d75d56 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -165,12 +165,13 @@ struct AgentSessionProfileTests { let chats = projects.appending(path: "-Users-me-App/chats") try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: projects) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) let id = "019f4f1b-3650-7661-a56d-351f02f01139" - try #"{"schema_version":1,"pid":555,"session_id":"\#(id)","work_dir":"/Users/me/App"}"# + try sidecarJSON(id: id, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 + 5) .write(to: chats.appending(path: "\(id).runtime.json"), atomically: true, encoding: .utf8) try "{}".write(to: chats.appending(path: "\(id).jsonl"), atomically: true, encoding: .utf8) - let session = QwenRuntimeStatus.session(projectsRoot: projects, pid: 555) + let session = QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) #expect(session?.id == id) #expect(session?.source == .processLog) #expect(session?.confidence == .exact) @@ -179,7 +180,44 @@ struct AgentSessionProfileTests { session?.transcriptPath?.resolvingSymlinksInPath() == chats.appending(path: "\(id).jsonl").resolvingSymlinksInPath() ) - #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 556) == nil) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 556, processStartedAt: processStartedAt) == nil) + } + + @Test func qwenRuntimeSidecarRejectsStaleClaimsFromReusedPids() throws { + let projects = FileManager.default.temporaryDirectory + .appending(path: "prowl-qwen-stale-\(UUID().uuidString)", directoryHint: .isDirectory) + let chats = projects.appending(path: "-Users-me-App/chats") + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projects) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) + + // Sidecars survive quit/crash; a claim older than the live process's start + // time belongs to a previous owner of the reused pid. + let stale = "11111111-2222-3333-4444-555555555555" + try sidecarJSON(id: stale, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 - 3_600) + .write(to: chats.appending(path: "\(stale).runtime.json"), atomically: true, encoding: .utf8) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) == nil) + + // Unknown schema versions are not ours to interpret. + let futuristic = "66666666-7777-8888-9999-000000000000" + try sidecarJSON(id: futuristic, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 + 5, schema: 2) + .write(to: chats.appending(path: "\(futuristic).runtime.json"), atomically: true, encoding: .utf8) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) == nil) + } + + private func sidecarJSON(id: String, pid: Int, startedAt: TimeInterval, schema: Int = 1) -> String { + #"{"schema_version":\#(schema),"pid":\#(pid),"session_id":"\#(id)","work_dir":"/Users/me/App","# + + #""hostname":"test","started_at":\#(startedAt),"qwen_version":"0.23.0"}"# + } + + @Test func qwenRootsNarrowToSanitizedProjectDirectory() { + let cwd = URL(fileURLWithPath: "/private/tmp/prowl_556.enc", isDirectory: true) + let qwen = AgentSessionProfile.profile(for: .qwen) + #expect( + qwen.candidateRoots(home, cwd, now, now).map(\.path) + == ["/Users/me/.qwen/projects/-private-tmp-prowl-556-enc/chats"] + ) + #expect(qwen.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.qwen/projects"]) } // MARK: - Amp thread log parsing