From fbadef2907e1cb35a80371159b2097d3f2482f5e Mon Sep 17 00:00:00 2001 From: howdeploy Date: Mon, 12 Oct 2026 15:20:42 +0300 Subject: [PATCH 1/2] Prepare CanvasTTY 1.8.0 and fix usage release blockers --- CHANGELOG.md | 11 ++++++++++- CHANGELOG.ru.md | 11 +++++++++++ CHANGELOG.zh-CN.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/main/services/LocalUsageCollector.ts | 2 ++ src/main/services/UsageHistoryService.ts | 12 +++++++++--- tests/usage-account-scope.test.mjs | 9 +++++++-- tests/usage-attribution-integration.test.mjs | 2 +- tests/usage-collector.test.mjs | 6 +++--- tests/usage-history-service.test.mjs | 4 ++-- tests/usage-ui.test.mjs | 3 ++- 12 files changed, 61 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca621af6..172bd42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) -## Unreleased +## 1.8.0 + +- Added image/material cards, pinned versions, remarks, and handoff to an existing agent session. The handoff icon stays fixed in the card header, and the dialog places its Cancel/Send actions together at the bottom right (#123, #128, #130). +- Added usage history with measured quota changes, conditional session estimates, local-log token evidence, and source health. Five sections in the left sidebar replace the long report: conditional estimate, quotas, agents, local-log tokens, and source state. Estimates remain explicitly distinct from measured usage (#138). +- Added interactive mascots and a Codex workflow for creating them (#139). +- Added per-agent default launch profiles and opening terminal file links in VS Code (#127, #129). +- Fixed macOS text-field clipboard/edit menus, OpenCode plugin exports, and native PTY file-descriptor leaks (#126, #131, #140). +- Fixed minimap navigation by fitting the camera viewport and all canvas objects together, keeping the camera outline inside the overview. +- Fixed usage collection creating WAL side files beside stopped Hermes databases, and blocked symlinked usage-history files on Windows. Completed the lockfile for clean npm 10 installs. +- Thanks to @tab11pm, @SaneSanders, @BIackFIame, @pepechka-kun, @CLOSETTTY, @teo-nex, @Yt4aZaveta, and @theorangecolorv2. Original contributor commits are preserved in the merge history. - Added a rich file viewer to the **Files** card: Markdown files (`.md`, `.markdown`, `.mdown`, `.mkd`) render as formatted GitHub-Flavored Markdown with headings, lists, task lists, tables, blockquotes, horizontal rules, inline code, and fenced code blocks with syntax highlighting, recognized code files are syntax-highlighted by the language detected from their extension while unknown languages stay plain monospaced text, all rendered content is sanitized so raw HTML, scripts, event-handler attributes, and dangerous link schemes never render, `http(s)` links open in the app's built-in browser with other schemes inert, only embedded `data:` images show while remote or relative sources fall back to alt text or a placeholder without a network request, and text and code files above the rich-rendering size threshold fall back to plain text with a notice. - Added a read-only **Files** canvas card and reader: open it from the empty-canvas context menu or the `Cmd/Ctrl+K` palette, root it at a live terminal session working directory or a folder picked in a native dialog, browse a directory tree that loads on demand, and read text/code as selectable monospace (bounded at 2 MB with a truncation marker and total size) or supported images (bounded at 25 MB) with explicit binary, unsupported, and too-large states. Quick open searches file names inside the active root, each card's root, bounds, open file, and expanded folders persist and restore with an unavailable state when the root is gone, and a new main-process `FileAccessService` enforces `realpath` containment so every listing, read, and search uses relative paths strictly inside registered roots with no write, rename, or delete capability. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 737649938..452233b08 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -2,6 +2,17 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) +## 1.8.0 + +- Добавлены карточки изображений и материалов, закреплённые версии, замечания и передача действующей сессии агента. Иконка передачи неподвижна в заголовке; «Отмена» и «Отправить» расположены вместе внизу справа (#123, #128, #130). +- Добавлены файловые карточки с деревом каталогов и просмотром Markdown, кода, текста и изображений без редактирования файлов (#125). +- Добавлена история использования: изменения квот, условные оценки по сессиям, токены из локальных логов и состояние источников. Пять разделов слева заменяют длинный отчёт; оценки явно отделены от измерений (#138). +- Добавлены интерактивные маскоты и их создание через Codex (#139). +- Добавлены настройки запуска по умолчанию для каждого агента и открытие файловых ссылок терминала в VS Code (#127, #129). +- Исправлены меню буфера обмена macOS, экспорт плагина OpenCode и утечки файловых дескрипторов PTY на macOS (#126, #131, #140). +- Миникарта теперь вмещает камеру и все объекты вместе, не выпуская белую рамку за границы обзора. +- Чтение остановленной базы Hermes больше не создаёт WAL-файлы; чтение истории по символической ссылке заблокировано и на Windows. Исправлен lockfile для чистой установки через npm 10. + ## 1.7.1 - Добавлены обновления стабильных версий из приложения: загрузка и установка по запросу, подтверждение остановки активных сессий и подписанные обновления Sparkle на macOS. Первую версию с этой функцией нужно установить вручную; последующие совместимые версии можно устанавливать из приложения. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 303881fea..ffadf625e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,17 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) +## 1.8.0 + +- 新增图片与材料卡片、固定版本、批注及向现有智能体会话发送批注。发送图标固定在卡片标题栏,取消与发送按钮并排置于对话框右下角(#123、#128、#130)。 +- 新增只读文件卡片、目录树及 Markdown、代码、文本和图片预览(#125)。 +- 新增使用历史:额度变化、会话条件估算、本地日志 token 证据及来源状态。左侧五个分区替代长报告,估算与实际测量明确区分(#138)。 +- 新增交互式吉祥物及通过 Codex 创建吉祥物的流程(#139)。 +- 新增各智能体的默认启动配置及在 VS Code 中打开终端文件链接(#127、#129)。 +- 修复 macOS 剪贴板菜单、OpenCode 插件导出及 macOS PTY 文件描述符泄漏(#126、#131、#140)。 +- 小地图同时容纳镜头视口与所有画布对象,白色边框保持在概览范围内。 +- 读取已停止的 Hermes 数据库不再创建 WAL 文件;Windows 同样拒绝通过符号链接读取使用历史。补全 npm 10 全新安装所需的 lockfile。 + ## 1.7.1 - 新增应用内稳定版本更新:由用户下载和安装,停止活动会话前确认,macOS 使用 Sparkle 签名更新。首个支持更新的版本需要手动安装;后续兼容版本可在应用内安装。 diff --git a/package-lock.json b/package-lock.json index b1a3a6e86..390c722a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "canvastty", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "canvastty", - "version": "1.7.1", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index a4f920824..1eaf865e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canvastty", - "version": "1.7.1", + "version": "1.8.0", "private": true, "description": "A spatial desktop for local AI agents and real terminal sessions.", "desktopName": "CanvasTTY", diff --git a/src/main/services/LocalUsageCollector.ts b/src/main/services/LocalUsageCollector.ts index ad030c810..2ab948d52 100644 --- a/src/main/services/LocalUsageCollector.ts +++ b/src/main/services/LocalUsageCollector.ts @@ -403,6 +403,8 @@ export class LocalUsageCollector { const target = (query: string): string => `${pathToFileURL(database).href}?${query}`; const [wal, shm] = await Promise.all([sideFile(`${database}-wal`), sideFile(`${database}-shm`)]); if (shm === null && wal) throw new SourceProblem('WAL without shared-memory index (writer stopped uncleanly); skipped to avoid a stale read'); + // A read-only WAL connection can still create side files beside a stopped writer. + if (shm === null && !wal) return await this.query(target('mode=ro&immutable=1'), sql); try { return await this.query(target('mode=ro&readonly_shm=1'), sql); } catch (error) { diff --git a/src/main/services/UsageHistoryService.ts b/src/main/services/UsageHistoryService.ts index 275bccf5d..c57fc16d4 100644 --- a/src/main/services/UsageHistoryService.ts +++ b/src/main/services/UsageHistoryService.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { copyFile, open, rename, unlink, type FileHandle } from "node:fs/promises"; +import { copyFile, lstat, open, rename, unlink, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { LimitProviderId, LimitsSnapshot } from "../../shared/contracts.ts"; @@ -117,9 +117,15 @@ export class UsageHistoryService { let raw: string; let handle: FileHandle | undefined; try { - // O_NOFOLLOW: a planted symlink is neither read nor, later, written through. + // Windows lacks O_NOFOLLOW; check the path before and after opening too. + if ((await lstat(this.filePath)).isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" }); handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - if (!(await handle.stat()).isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" }); + const opened = await handle.stat(); + const current = await lstat(this.filePath); + if (current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + throw Object.assign(new Error("history path changed"), { code: "ELOOP" }); + } + if (!opened.isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" }); raw = await handle.readFile("utf8"); } catch (error) { if (errorCode(error) === "ENOENT") return; diff --git a/tests/usage-account-scope.test.mjs b/tests/usage-account-scope.test.mjs index fde353b22..787dc92f8 100644 --- a/tests/usage-account-scope.test.mjs +++ b/tests/usage-account-scope.test.mjs @@ -35,7 +35,8 @@ function registry(executables) { state: "available", provider, executable, - launcher: "native", + launcher: process.platform === "win32" ? "batch" : "native", + commandPrompt: process.env.ComSpec, environment: Object.freeze({ PATH: process.env.PATH ?? "/usr/bin:/bin" }), checked: Object.freeze([]) }) @@ -78,9 +79,13 @@ createInterface({ input: process.stdin }).on("line", (line) => { }); `); await chmod(script, 0o755); + const executable = process.platform === "win32" ? join(root, "fake-codex.cmd") : script; + if (process.platform === "win32") { + await writeFile(executable, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + } return { codexHome, - script, + script: executable, spawns: async () => (await readFile(join(root, "spawns"), "utf8").catch(() => "")).split("\n").filter(Boolean).length }; } diff --git a/tests/usage-attribution-integration.test.mjs b/tests/usage-attribution-integration.test.mjs index 2569c4570..1d06076c3 100644 --- a/tests/usage-attribution-integration.test.mjs +++ b/tests/usage-attribution-integration.test.mjs @@ -239,7 +239,7 @@ test("a read backlog defers estimates; the next complete run supplies the late e await h.service.dispose(); }); -test("recorded health: an unreadable log blocks estimates until read; a lost record blocks only clusters it may have held", { skip: process.getuid?.() === 0 }, async (t) => { +test("recorded health: an unreadable log blocks estimates until read; a lost record blocks only clusters it may have held", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async (t) => { const home = await temporary(t, "usage-int-home-"); const directory = await temporary(t, "usage-int-data-"); const ids = await writeLogs(home); diff --git a/tests/usage-collector.test.mjs b/tests/usage-collector.test.mjs index dc5ffe307..50e198fff 100644 --- a/tests/usage-collector.test.mjs +++ b/tests/usage-collector.test.mjs @@ -193,7 +193,7 @@ test('Hermes queries select accounting columns only, read-only, and never touch }).collect(T0); const statements = argumentsSeen.filter((argument) => /^\s*SELECT/i.test(argument)); assert.equal(statements.length, 2); - assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro&readonly_shm=1$/.test(a))); + assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro&immutable=1$/.test(a))); for (const statement of statements) { assert.doesNotMatch(statement, /\*|system_prompt|title|messages|content|INSERT|UPDATE|DELETE|CREATE|DROP|ATTACH|PRAGMA\s/i); } @@ -467,7 +467,7 @@ test('Collector: byte budget resumes backlog, overlapping calls do not duplicate const results = await Promise.all([concurrent.collect(NOW), concurrent.collect(NOW), concurrent.collect(NOW)]); assert.deepEqual(results.flatMap((r) => r.events.map((e) => e.id)), reference); - if (process.getuid?.() !== 0) { + if (process.platform !== 'win32' && process.getuid?.() !== 0) { const locked = rollout(home, randomUUID()); await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]); await chmod(locked, 0); @@ -523,7 +523,7 @@ test('Collector health: complete only when every discovered log was read to its const after = await collector.collect(NOW); assert.deepEqual([after.complete, after.lost], [true, false], 'a loss is reported once, by the run that consumed it'); - if (process.getuid?.() !== 0) { + if (process.platform !== 'win32' && process.getuid?.() !== 0) { const locked = rollout(home, randomUUID()); await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]); await chmod(locked, 0); diff --git a/tests/usage-history-service.test.mjs b/tests/usage-history-service.test.mjs index f2d177a26..82cfce4a8 100644 --- a/tests/usage-history-service.test.mjs +++ b/tests/usage-history-service.test.mjs @@ -142,7 +142,7 @@ test("first collection creates a private, atomically replaced history file with assert.deepEqual(limits.calls, [["codex", "claude"]]); assert.deepEqual(await readdir(directory), [FILE], "no temporary files remain"); - assert.equal((await stat(join(directory, FILE))).mode & 0o777, 0o600); + if (process.platform !== "win32") assert.equal((await stat(join(directory, FILE))).mode & 0o777, 0o600); const persisted = JSON.parse(await readFile(join(directory, FILE), "utf8")); assert.deepEqual(persisted.history.samples, collected.samples); assert.equal(JSON.stringify(history.get()).includes("\"files\""), false, "collector cursors stay private to the main process"); @@ -431,7 +431,7 @@ test("a symlinked history file is neither followed nor replaced", async () => { }); }); -test("save failures are surfaced without losing the in-memory history", { skip: process.getuid?.() === 0 }, async () => { +test("save failures are surfaced without losing the in-memory history", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => { await withDirectory(async (directory) => { const store = join(directory, "store"); await mkdir(store); diff --git a/tests/usage-ui.test.mjs b/tests/usage-ui.test.mjs index cd2a6579c..020c7534a 100644 --- a/tests/usage-ui.test.mjs +++ b/tests/usage-ui.test.mjs @@ -303,7 +303,8 @@ test("App mounts the trigger beside the title bar and portals the overlay outsid assert.match(app, /
\n\s* Date: Mon, 12 Oct 2026 15:31:11 +0300 Subject: [PATCH 2/2] fix(tests): keep usage fixtures portable across Windows and Unix --- tests/usage-collector.test.mjs | 4 ++-- tests/usage-ui.test.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/usage-collector.test.mjs b/tests/usage-collector.test.mjs index 50e198fff..ba0172ed4 100644 --- a/tests/usage-collector.test.mjs +++ b/tests/usage-collector.test.mjs @@ -467,9 +467,9 @@ test('Collector: byte budget resumes backlog, overlapping calls do not duplicate const results = await Promise.all([concurrent.collect(NOW), concurrent.collect(NOW), concurrent.collect(NOW)]); assert.deepEqual(results.flatMap((r) => r.events.map((e) => e.id)), reference); + const locked = rollout(home, randomUUID()); + await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]); if (process.platform !== 'win32' && process.getuid?.() !== 0) { - const locked = rollout(home, randomUUID()); - await write(locked, [codexMeta('locked', start), ...lineage(start, [[5, 0, 1]])]); await chmod(locked, 0); const blocked = await concurrent.collect(NOW); assert.deepEqual(blocked.events, []); diff --git a/tests/usage-ui.test.mjs b/tests/usage-ui.test.mjs index 020c7534a..554bfdff5 100644 --- a/tests/usage-ui.test.mjs +++ b/tests/usage-ui.test.mjs @@ -298,7 +298,7 @@ test("renders in English when the app locale is English", () => { }); test("App mounts the trigger beside the title bar and portals the overlay outside the canvas", async () => { - const app = await source("src/renderer/src/App.tsx"); + const app = (await source("src/renderer/src/App.tsx")).replaceAll("\r\n", "\n"); assert.match(app, /import \{ UsageHistoryPanel \} from "\.\/features\/usage\/UsageHistoryPanel";/); assert.match(app, /
\n\s*