pi-tty7-tab-namer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/README.zh-CN.md +76 -0
- package/extensions/index.ts +407 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ifyour
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/readme/hero.jpg" width="100%" alt="Multiple session tabs auto-named by pi-tty7-tab-namer in the tty7 tab bar">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">pi-tty7-tab-namer</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>pi extension</strong> · names sessions automatically with an LLM, synced to <a href="https://github.com/l0ng-ai/tty7">tty7</a> tab titles<br>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/pi-package-5fd0a0" alt="pi package">
|
|
14
|
+
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fifyour%2Fpi-tty7-tab-namer%2Fmain%2Fpackage.json&query=%24.version&label=version&color=blue" alt="version">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<sub>English · <a href="README.zh-CN.md">简体中文</a></sub>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Prerequisite: run pi inside the [tty7](https://github.com/l0ng-ai/tty7) terminal (this extension only activates in a tty7 environment).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install git:github.com/ifyour/pi-tty7-tab-namer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's it — nothing else to do. Names are also written to session metadata, so they show up readable in the `/resume` session list.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
| Scenario | Behavior |
|
|
34
|
+
|---|---|
|
|
35
|
+
| First message in a new session | Tab automatically shows a short (≤10 char) title |
|
|
36
|
+
| Loading an unnamed historical session | Names the **whole session** from the last 10 messages, not just the newly appended one |
|
|
37
|
+
| `/name custom` | Tab updates immediately, and auto-naming stops for that session |
|
|
38
|
+
| Manually renaming the tab in tty7 | Session name syncs immediately, and auto-naming stops (same as `/name`) |
|
|
39
|
+
| Resuming a named session | Tab shows the name immediately |
|
|
40
|
+
| Quitting pi | Tab falls back to the default tty7 title |
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Auto-naming**: on the first message, calls the current session model in parallel to summarize intent into a title, without blocking the conversation
|
|
45
|
+
- **Two-way sync**: manual tab rename in tty7 → writes the pi session name; naming inside pi → updates the tab
|
|
46
|
+
- **Manual wins**: after `/name` or a manual tty7 rename, auto-naming never overrides
|
|
47
|
+
- **Full lifecycle**: tab title follows the session name on `new` / `resume` / `fork` / `quit`
|
|
48
|
+
- **Race-safe**: switching or quitting while a naming request is in flight discards the result, never writing to the wrong session; 20s timeout with automatic retry on the next turn
|
|
49
|
+
- **Subagent isolation**: when the main session spawns subagents, child sessions never touch the tab title or session name (compatible with all pi sub-session mechanisms, e.g. pi-subagents and the built-in Task tool)
|
|
50
|
+
|
|
51
|
+
## FAQ
|
|
52
|
+
|
|
53
|
+
**Does it work in other terminals (iTerm2, etc.)?**
|
|
54
|
+
|
|
55
|
+
Not currently — this extension targets tty7 only.
|
|
56
|
+
|
|
57
|
+
**Does it auto-rename after multiple turns?**
|
|
58
|
+
|
|
59
|
+
A session is named once (unless done manually).
|
|
60
|
+
|
|
61
|
+
**Does the model call cost money?**
|
|
62
|
+
|
|
63
|
+
Each naming run is one tiny request (a few hundred tokens), triggered only the first time for an unnamed session.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/ifyour/pi-tty7-tab-namer
|
|
69
|
+
cd pi-tty7-tab-namer
|
|
70
|
+
npm test # self-check for title sanitization logic
|
|
71
|
+
pi -e . # try locally
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/readme/hero.jpg" width="100%" alt="tty7 Tab 栏中显示多个由 pi-tty7-tab-namer 自动命名的会话标签">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">pi-tty7-tab-namer</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>pi 扩展</strong> · 用大模型自动为会话命名,同步到 <a href="https://github.com/l0ng-ai/tty7">tty7</a> 的 Tab 标题<br>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/pi-package-5fd0a0" alt="pi package">
|
|
14
|
+
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fifyour%2Fpi-tty7-tab-namer%2Fmain%2Fpackage.json&query=%24.version&label=version&color=blue" alt="version">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<sub><a href="README.md">English</a> · 简体中文</sub>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
前提:在 [tty7](https://github.com/l0ng-ai/tty7) 终端内运行 pi(本扩展只在 tty7 环境下激活)。
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install git:github.com/ifyour/pi-tty7-tab-namer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
装好即可,无需任何操作。名字会同步写入会话元数据,`/resume` 会话列表里也直接可读。
|
|
30
|
+
|
|
31
|
+
## 使用场景
|
|
32
|
+
|
|
33
|
+
| 场景 | 行为 |
|
|
34
|
+
|---|---|
|
|
35
|
+
| 新会话发出第一条消息 | Tab 自动显示 ≤10 字中文标题 |
|
|
36
|
+
| 载入未命名的历史会话 | 基于最近 10 条消息为**整个会话**命名,而不只看追加的新消息 |
|
|
37
|
+
| `/name 自定义` | Tab 立即更新,且之后自动命名不再生效 |
|
|
38
|
+
| 在 tty7 里手动改 Tab 名 | 会话名立即同步为该名字,之后自动命名不再生效(等同 `/name`) |
|
|
39
|
+
| resume 已命名会话 | Tab 立即显示该名字 |
|
|
40
|
+
| 退出 pi | Tab 回落到 tty7 默认标题 |
|
|
41
|
+
|
|
42
|
+
## 特性
|
|
43
|
+
|
|
44
|
+
- **自动命名**:第一条消息发出时,并行调用当前会话模型总结意图生成标题,不阻塞对话
|
|
45
|
+
- **双向同步**:tty7 手动改 Tab 名 → 写入 pi 会话名;pi 内命名 → 更新 Tab
|
|
46
|
+
- **手动优先**:`/name` 或 tty7 手动改名后,自动命名永不覆盖
|
|
47
|
+
- **全生命周期跟随**:`new` / `resume` / `fork` / `quit` 时 Tab 标题跟随会话名字
|
|
48
|
+
- **竞态安全**:命名请求在途时切换/退出会话,结果被丢弃,不会写错会话;20 秒超时,失败后下一轮自动重试
|
|
49
|
+
- **子代理隔离**:主会话派发 subagent 时,子会话不会碰 Tab 标题或会话名,主会话命名不受影响(兼容所有基于 pi 子会话机制的 subagent,如 pi-subagents、内置 Task 工具)
|
|
50
|
+
|
|
51
|
+
## FAQ
|
|
52
|
+
|
|
53
|
+
**在其他终端(iTerm2 等)能用吗?**
|
|
54
|
+
|
|
55
|
+
目前不能,本扩展仅针对 tty7。
|
|
56
|
+
|
|
57
|
+
**支持多轮后自动改名吗?**
|
|
58
|
+
|
|
59
|
+
目前一个会话只命名一次(手动除外)。
|
|
60
|
+
|
|
61
|
+
**模型调用花钱吗?**
|
|
62
|
+
|
|
63
|
+
每次命名一个极小请求(约几百 token),仅在未命名会话首次触发时发生。
|
|
64
|
+
|
|
65
|
+
## 开发
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/ifyour/pi-tty7-tab-namer
|
|
69
|
+
cd pi-tty7-tab-namer
|
|
70
|
+
npm test # 标题清洗逻辑自检
|
|
71
|
+
pi -e . # 本地试用
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/* tty7 tab-name — auto-name pi sessions and sync the name to the tty7 tab title.
|
|
2
|
+
*
|
|
3
|
+
* - Unnamed session → parallel LLM call (does not block the turn) summarizing
|
|
4
|
+
* intent into a ≤10-char name → setSessionName + tab title. Naming looks at
|
|
5
|
+
* the session's user messages (last 10) so a resumed old session gets named
|
|
6
|
+
* for the whole conversation, not just the latest prompt.
|
|
7
|
+
* - Triggers: session_start (resume of an unnamed session) and the first
|
|
8
|
+
* before_agent_start while still unnamed (covers fresh sessions and retries).
|
|
9
|
+
* - Manual `/name <x>` wins: once a name arrives from outside this extension,
|
|
10
|
+
* auto-naming never runs again for that session.
|
|
11
|
+
* - resume/new/fork/quit: title follows the session's name; on shutdown the
|
|
12
|
+
* title is reset so the tab falls back to tty7's default.
|
|
13
|
+
* - Reverse sync: a manual tab rename inside tty7 (tab_renamed event from
|
|
14
|
+
* `tty7 events --json`) is treated like `/name` — manual priority, auto-
|
|
15
|
+
* naming never overrides it afterwards. Bidirectional, no loop: our own
|
|
16
|
+
* OSC writes never emit tab_renamed.
|
|
17
|
+
* - quit: the events listener child process is killed; if it dies mid-session
|
|
18
|
+
* reverse sync goes silent until the next session_start.
|
|
19
|
+
*
|
|
20
|
+
* Title channel note: pi core also writes the title (`π - name - cwd`) in its
|
|
21
|
+
* own session_info_changed handler, which runs AFTER extension handlers. All
|
|
22
|
+
* our title writes are therefore deferred by one tick to land last, and we
|
|
23
|
+
* write exactly `{name}`.
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
27
|
+
import { appendFile, stat, truncate } from "node:fs/promises";
|
|
28
|
+
|
|
29
|
+
// Lightweight always-on trace log: one JSON line per naming decision, so a
|
|
30
|
+
// silent failure (timeout/model error/gen race/manual override) can be
|
|
31
|
+
// diagnosed after the fact. Truncated at ~256 KB to stay bounded.
|
|
32
|
+
const LOG_PATH = `${process.env["HOME"] ?? ""}/.pi/agent/tty7-tab-namer.log`;
|
|
33
|
+
const LOG_MAX_BYTES = 256 * 1024;
|
|
34
|
+
function log(event: string, detail: Record<string, unknown> = {}) {
|
|
35
|
+
void (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const s = await stat(LOG_PATH).catch(() => null);
|
|
38
|
+
if (s && s.size > LOG_MAX_BYTES) await truncate(LOG_PATH);
|
|
39
|
+
await appendFile(LOG_PATH, JSON.stringify({ t: new Date().toISOString(), event, ...detail }) + "\n");
|
|
40
|
+
} catch {}
|
|
41
|
+
})();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NAME_PROMPT =
|
|
45
|
+
"根据下面的用户消息记录,为这个会话起一个简短标题:不超过10个字,概括整个会话的主要意图,中文,不加引号、句号或任何前后缀,直接输出标题本身。\n\n<user-messages>\n";
|
|
46
|
+
const MAX_TOKENS = 512;
|
|
47
|
+
const NAME_TIMEOUT_MS = 20_000;
|
|
48
|
+
const NAME_RETRIES = 3; // total attempts; backoff 2s → 4s between retries
|
|
49
|
+
const HISTORY_MESSAGES = 10;
|
|
50
|
+
const HISTORY_CHARS = 2000;
|
|
51
|
+
// Subagent auto-name pattern (pi-subagents names child sessions "type#hex8",
|
|
52
|
+
// e.g. "general-purpose#c897cd1c"); such names must never reach the tab.
|
|
53
|
+
const SUBAGENT_NAME = /^[\w-]+#[0-9a-f]{8}$/i;
|
|
54
|
+
// tty7 CLI resolves via PATH (installed at /usr/local/bin/tty7 by the app);
|
|
55
|
+
// fall back to the app bundle binary for installs without the symlink.
|
|
56
|
+
const TTY7_EXE = process.env["TTY7_CLI"] ?? "/usr/local/bin/tty7";
|
|
57
|
+
|
|
58
|
+
/** Extract a clean title from raw model output. Exported for the self-test. */
|
|
59
|
+
export function sanitizeTitle(raw: string): string {
|
|
60
|
+
const line = raw
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((l) => l.trim())
|
|
63
|
+
.find((l) => l.length > 0);
|
|
64
|
+
let t = (line ?? "").replace(/^["'「『《]|[」』》"'.,。]+$/g, "").trim();
|
|
65
|
+
if (t.length > 20) t = t.slice(0, 20);
|
|
66
|
+
return t;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function messageText(content: unknown): string {
|
|
70
|
+
if (typeof content === "string") return content;
|
|
71
|
+
if (Array.isArray(content)) {
|
|
72
|
+
return content
|
|
73
|
+
.filter((b): b is { type: "text"; text: string } => b?.type === "text")
|
|
74
|
+
.map((b) => b.text)
|
|
75
|
+
.join(" ");
|
|
76
|
+
}
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Recent user messages of the current branch, oldest first. */
|
|
81
|
+
function historyUserMessages(ctx: ExtensionContext): string[] {
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
try {
|
|
84
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
85
|
+
if (entry.type !== "message") continue;
|
|
86
|
+
const msg = entry.message as { role?: string; content?: unknown };
|
|
87
|
+
if (msg?.role !== "user") continue;
|
|
88
|
+
const text = messageText(msg.content).trim();
|
|
89
|
+
if (text) out.push(text);
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
return out.slice(-HISTORY_MESSAGES);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function (pi: ExtensionAPI) {
|
|
96
|
+
if (!process.env["TTY7"]) return; // bridge convention: only inside tty7
|
|
97
|
+
|
|
98
|
+
// Subagent sessions (pi-subagents spawns them in-process via
|
|
99
|
+
// createAgentSession; they persist a parentSession header and share the same
|
|
100
|
+
// tab) must never touch the title, or the child's name (e.g.
|
|
101
|
+
// "general-purpose#c897cd1c") clobbers the main session's name.
|
|
102
|
+
const isSubagent = (ctx: ExtensionContext): boolean => {
|
|
103
|
+
try {
|
|
104
|
+
return Boolean(ctx.sessionManager.getHeader()?.parentSession);
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let gen = 0; // bumped on every session transition; in-flight naming must match
|
|
111
|
+
let manual = false; // a name arrived that we did not set
|
|
112
|
+
let suppressInfo = false; // the next session_info_changed is our own setSessionName
|
|
113
|
+
let inFlight = false;
|
|
114
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
115
|
+
let lastCustom: string | null = null; // title we manage; null = leave terminal default
|
|
116
|
+
|
|
117
|
+
// --- reverse sync: tty7 tab rename → session name -----------------------
|
|
118
|
+
// tty7 emits tab_renamed ONLY for manual renames (OSC title writes land in
|
|
119
|
+
// the tab's label and fire pane_facts instead — verified). So any matching
|
|
120
|
+
// rename event is user intent and gets manual priority.
|
|
121
|
+
let eventsProc: ChildProcess | null = null;
|
|
122
|
+
let ourTabId: string | null = null;
|
|
123
|
+
let eventsBuf = "";
|
|
124
|
+
let selfRename: string | null = null; // tab rename we issued ourselves (ignore the echoed event)
|
|
125
|
+
let tabNameOurs = false; // tab's name field was last written by this extension (not the user)
|
|
126
|
+
|
|
127
|
+
function resolveTabId(): string | null {
|
|
128
|
+
const pane = Number(process.env["TTY7_PANE"]);
|
|
129
|
+
if (!Number.isFinite(pane)) return null;
|
|
130
|
+
try {
|
|
131
|
+
const out = spawnSync(TTY7_EXE, ["tab", "ls", "--json"], { encoding: "utf8", timeout: 5000 });
|
|
132
|
+
const tabs = (JSON.parse(out.stdout ?? "{}") as { tabs?: Array<{ id: string; panes: number[] }> }).tabs ?? [];
|
|
133
|
+
return tabs.find((t) => t.panes.includes(pane))?.id ?? null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stopEvents() {
|
|
140
|
+
eventsProc?.kill();
|
|
141
|
+
eventsProc = null;
|
|
142
|
+
eventsBuf = "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let lastCtx: ExtensionContext | null = null; // for ui.notify outside event handlers
|
|
146
|
+
|
|
147
|
+
function startEvents(pi: ExtensionAPI) {
|
|
148
|
+
stopEvents();
|
|
149
|
+
const newTabId = resolveTabId();
|
|
150
|
+
if (newTabId !== ourTabId) appliedTabName = null; // new tab → its name field state is unknown
|
|
151
|
+
ourTabId = newTabId;
|
|
152
|
+
try {
|
|
153
|
+
eventsProc = spawn(TTY7_EXE, ["events", "--json"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
154
|
+
} catch {
|
|
155
|
+
return; // silent: reverse sync is best-effort
|
|
156
|
+
}
|
|
157
|
+
eventsProc.stdout!.on("data", (chunk: Buffer) => {
|
|
158
|
+
eventsBuf += chunk.toString();
|
|
159
|
+
let nl: number;
|
|
160
|
+
while ((nl = eventsBuf.indexOf("\n")) >= 0) {
|
|
161
|
+
const line = eventsBuf.slice(0, nl).trim();
|
|
162
|
+
eventsBuf = eventsBuf.slice(nl + 1);
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
let renamed: { tab?: string; name?: string } | undefined;
|
|
165
|
+
try {
|
|
166
|
+
const evt = JSON.parse(line) as { layout?: { delta?: { tab_renamed?: { tab: string; name: string } } } };
|
|
167
|
+
renamed = evt.layout?.delta?.tab_renamed;
|
|
168
|
+
} catch {
|
|
169
|
+
continue; // partial/malformed line
|
|
170
|
+
}
|
|
171
|
+
if (!renamed || typeof renamed.name !== "string") continue;
|
|
172
|
+
// Echo of our own forward sync → not user intent.
|
|
173
|
+
if (selfRename !== null && renamed.name === selfRename) {
|
|
174
|
+
selfRename = null;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
selfRename = null;
|
|
178
|
+
// Lazy re-resolve: session may have moved to another tab (resume etc.).
|
|
179
|
+
if (ourTabId !== renamed.tab) {
|
|
180
|
+
ourTabId = resolveTabId();
|
|
181
|
+
if (ourTabId !== renamed.tab) continue;
|
|
182
|
+
}
|
|
183
|
+
const name = renamed.name.trim();
|
|
184
|
+
if (!name) continue; // cleared/whitespace rename → ignore
|
|
185
|
+
if (pi.getSessionName() === name) continue; // no-op rename
|
|
186
|
+
manual = true; // user intent: auto-naming never overrides from here
|
|
187
|
+
log("reverse-sync", { name });
|
|
188
|
+
tabNameOurs = false; // the user owns the tab name now
|
|
189
|
+
appliedTabName = name;
|
|
190
|
+
suppressInfo = true;
|
|
191
|
+
pi.setSessionName(name);
|
|
192
|
+
applyName(name);
|
|
193
|
+
lastCtx?.ui.notify(`Session name set: ${name}`, "info");
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
eventsProc.on("exit", () => {
|
|
197
|
+
eventsProc = null;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// pi core rewrites the title after extension handlers on session_info_changed;
|
|
202
|
+
// a one-tick defer makes our write the final one.
|
|
203
|
+
let pendingTitle: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
function setTitleSoon(title: string) {
|
|
205
|
+
if (pendingTitle) clearTimeout(pendingTitle);
|
|
206
|
+
pendingTitle = setTimeout(() => {
|
|
207
|
+
pendingTitle = null;
|
|
208
|
+
try {
|
|
209
|
+
process.stdout.write(`\x1B]0;${title}\x07`);
|
|
210
|
+
} catch {}
|
|
211
|
+
}, 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let appliedTabName: string | null = null; // name currently on the tab's name field
|
|
215
|
+
|
|
216
|
+
// Forward sync: keep the tab's `name` field equal to the session name via
|
|
217
|
+
// `tab rename`. The OSC title only updates the tab's `label`, which the GUI
|
|
218
|
+
// hides once a user rename has set `name` (verified) — so session-side
|
|
219
|
+
// renames must go through the CLI to stay visible. tab rename accepts the
|
|
220
|
+
// tab UUID directly.
|
|
221
|
+
function tabRename(name: string) {
|
|
222
|
+
if (name === appliedTabName) return;
|
|
223
|
+
if (!ourTabId) ourTabId = resolveTabId();
|
|
224
|
+
if (!ourTabId) return;
|
|
225
|
+
appliedTabName = name;
|
|
226
|
+
tabNameOurs = true;
|
|
227
|
+
selfRename = name; // ignore the tab_renamed event this will echo back
|
|
228
|
+
try {
|
|
229
|
+
spawn(TTY7_EXE, ["tab", "rename", ourTabId, name], { stdio: "ignore" }).on("error", () => {});
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyName(name: string | null) {
|
|
234
|
+
if (name) {
|
|
235
|
+
lastCustom = name;
|
|
236
|
+
setTitleSoon(name);
|
|
237
|
+
tabRename(name);
|
|
238
|
+
} else if (lastCustom !== null) {
|
|
239
|
+
lastCustom = null;
|
|
240
|
+
setTitleSoon("");
|
|
241
|
+
// Reset the tab name too — but only if we wrote it; a user-chosen
|
|
242
|
+
// tab name survives the session ("退出 pi → Tab 回落默认" only
|
|
243
|
+
// applies to names the extension set).
|
|
244
|
+
if (tabNameOurs && ourTabId) {
|
|
245
|
+
tabNameOurs = false;
|
|
246
|
+
appliedTabName = null;
|
|
247
|
+
try {
|
|
248
|
+
spawn(TTY7_EXE, ["tab", "rename", ourTabId, ""], { stdio: "ignore" }).on("error", () => {});
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function tryName(ctx: ExtensionContext, latestPrompt?: string) {
|
|
255
|
+
const skip = (why: string) => log("skip", { why, prompt: Boolean(latestPrompt), sessionId: ctx.sessionManager.getSessionId() });
|
|
256
|
+
if (manual) return skip("manual");
|
|
257
|
+
if (pi.getSessionName()) return skip("already-named");
|
|
258
|
+
if (inFlight) return skip("in-flight");
|
|
259
|
+
if (!ctx.model) return skip("no-model");
|
|
260
|
+
const messages = historyUserMessages(ctx);
|
|
261
|
+
if (latestPrompt) messages.push(latestPrompt);
|
|
262
|
+
if (messages.length === 0) return skip("no-messages");
|
|
263
|
+
let context = messages.join("\n---\n");
|
|
264
|
+
if (context.length > HISTORY_CHARS) context = context.slice(-HISTORY_CHARS);
|
|
265
|
+
const myGen = gen;
|
|
266
|
+
const model = ctx.model;
|
|
267
|
+
inFlight = true;
|
|
268
|
+
log("naming-start", { context: context.slice(-200), sessionId: ctx.sessionManager.getSessionId() });
|
|
269
|
+
// Fire-and-forget: naming must not delay the user's turn. On failure
|
|
270
|
+
// retry up to NAME_RETRIES attempts total with exponential backoff, so a
|
|
271
|
+
// flaky first call still names the session within seconds.
|
|
272
|
+
void (async () => {
|
|
273
|
+
for (let attempt = 1; attempt <= NAME_RETRIES; attempt++) {
|
|
274
|
+
let name = "";
|
|
275
|
+
try {
|
|
276
|
+
const request = ctx.modelRegistry.complete(
|
|
277
|
+
model,
|
|
278
|
+
{
|
|
279
|
+
messages: [
|
|
280
|
+
{
|
|
281
|
+
role: "user" as const,
|
|
282
|
+
content: [{ type: "text" as const, text: `${NAME_PROMPT}${context}\n</user-messages>` }],
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{ maxTokens: MAX_TOKENS, cacheRetention: "none", sessionId: crypto.randomUUID() },
|
|
288
|
+
);
|
|
289
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), NAME_TIMEOUT_MS));
|
|
290
|
+
const response = await Promise.race([request, timeout]);
|
|
291
|
+
if (!response) {
|
|
292
|
+
log("naming-failed", { why: "timeout", attempt, ms: NAME_TIMEOUT_MS });
|
|
293
|
+
} else {
|
|
294
|
+
const text = response.content
|
|
295
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
296
|
+
.map((c) => c.text)
|
|
297
|
+
.join(" ");
|
|
298
|
+
name = sanitizeTitle(text);
|
|
299
|
+
if (!name) log("naming-failed", { why: "empty-after-sanitize", attempt, raw: text.slice(0, 100) });
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
log("naming-failed", { why: "error", attempt, error: err instanceof Error ? err.message : String(err) });
|
|
303
|
+
}
|
|
304
|
+
if (name) {
|
|
305
|
+
// On a gen change session_start already reset inFlight for the new
|
|
306
|
+
// session — leave it alone.
|
|
307
|
+
if (myGen !== gen) return log("naming-dropped", { why: "gen-changed" });
|
|
308
|
+
inFlight = false;
|
|
309
|
+
if (manual) return log("naming-dropped", { why: "manual" });
|
|
310
|
+
if (pi.getSessionName()) return log("naming-dropped", { why: "already-named" });
|
|
311
|
+
log("naming-done", { name, attempt });
|
|
312
|
+
suppressInfo = true;
|
|
313
|
+
pi.setSessionName(name);
|
|
314
|
+
applyName(name);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (attempt < NAME_RETRIES) {
|
|
318
|
+
const backoff = 2 ** attempt * 1000; // 2s, 4s
|
|
319
|
+
let aborted: "gen" | null = null;
|
|
320
|
+
const iv = setInterval(() => {
|
|
321
|
+
// Session switched away or user named it: stop retrying.
|
|
322
|
+
if (myGen !== gen || manual) aborted = "gen";
|
|
323
|
+
}, 250);
|
|
324
|
+
await new Promise((resolve) => {
|
|
325
|
+
retryTimer = setTimeout(() => {
|
|
326
|
+
clearInterval(iv);
|
|
327
|
+
resolve(null);
|
|
328
|
+
}, backoff);
|
|
329
|
+
});
|
|
330
|
+
clearInterval(iv);
|
|
331
|
+
if (aborted || manual) {
|
|
332
|
+
inFlight = false;
|
|
333
|
+
return log("naming-dropped", { why: manual ? "manual" : "gen-changed" });
|
|
334
|
+
}
|
|
335
|
+
log("naming-retry", { attempt: attempt + 1, backoffMs: backoff });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (myGen === gen) inFlight = false;
|
|
339
|
+
log("naming-failed", { why: "exhausted", attempts: NAME_RETRIES });
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
pi.on("session_start", (event, ctx) => {
|
|
344
|
+
if (isSubagent(ctx)) return; // child agent process: the tab belongs to the main session
|
|
345
|
+
lastCtx = ctx;
|
|
346
|
+
gen++;
|
|
347
|
+
manual = false;
|
|
348
|
+
suppressInfo = false;
|
|
349
|
+
inFlight = false;
|
|
350
|
+
if (retryTimer) {
|
|
351
|
+
clearTimeout(retryTimer); // kill any pending backoff from the old session
|
|
352
|
+
retryTimer = null;
|
|
353
|
+
}
|
|
354
|
+
// /new is a full new cycle: the tab always falls back to the default, even
|
|
355
|
+
// when the current name came from a manual tty7 rename (reverse sync).
|
|
356
|
+
if (event.reason === "new" && appliedTabName) {
|
|
357
|
+
tabRename("");
|
|
358
|
+
tabNameOurs = false;
|
|
359
|
+
} else if (tabNameOurs && ourTabId) {
|
|
360
|
+
// Switching sessions: a tab name we wrote for the previous session no
|
|
361
|
+
// longer applies; clear it before the new session's name lands.
|
|
362
|
+
tabNameOurs = false;
|
|
363
|
+
appliedTabName = null;
|
|
364
|
+
try {
|
|
365
|
+
spawn(TTY7_EXE, ["tab", "rename", ourTabId, ""], { stdio: "ignore" }).on("error", () => {});
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
368
|
+
const storedName = pi.getSessionName();
|
|
369
|
+
// A leaked subagent name ("type#hex8") may have been persisted into this
|
|
370
|
+
// session's file by pi-subagents; never display or trust it.
|
|
371
|
+
applyName(storedName && !SUBAGENT_NAME.test(storedName) ? storedName : null);
|
|
372
|
+
startEvents(pi);
|
|
373
|
+
// Resumed a session that was never named → name it from its history now.
|
|
374
|
+
if (event.reason === "resume" || event.reason === "fork") {
|
|
375
|
+
log("trigger", { reason: event.reason });
|
|
376
|
+
tryName(ctx);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
pi.on("session_info_changed", (event, ctx) => {
|
|
381
|
+
// Subagents run in-process and setSessionName("type#hex8") on their child
|
|
382
|
+
// session; that event reaches us here and would clobber the main session's
|
|
383
|
+
// tab name (and flip manual=true, killing auto-naming). Ignore both the
|
|
384
|
+
// child-context delivery and the leaked name pattern itself.
|
|
385
|
+
if (isSubagent(ctx) || SUBAGENT_NAME.test(event.name ?? "")) return;
|
|
386
|
+
if (suppressInfo) {
|
|
387
|
+
suppressInfo = false;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
manual = event.name !== undefined;
|
|
391
|
+
if (event.name) applyName(event.name);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
395
|
+
if (isSubagent(ctx)) return; // never touched the tab, nothing to reset
|
|
396
|
+
gen++;
|
|
397
|
+
stopEvents();
|
|
398
|
+
applyName(null);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
402
|
+
if (isSubagent(ctx)) return;
|
|
403
|
+
// First prompt of an unnamed session, or a retry after a failed attempt.
|
|
404
|
+
log("trigger", { reason: "before-agent-start" });
|
|
405
|
+
tryName(ctx, event.prompt);
|
|
406
|
+
});
|
|
407
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-tty7-tab-namer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Auto-name pi sessions with an LLM and sync the name to the tty7 tab title. Manual /name always wins.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": ["extensions", "README.md"],
|
|
7
|
+
"keywords": ["pi-package", "pi", "tty7", "terminal", "tab-title", "session-name"],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ifyour/pi-tty7-tab-namer.git"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["extensions/*.ts"]
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-agent-core": "*",
|
|
18
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"@earendil-works/pi-agent-core": { "optional": true },
|
|
22
|
+
"@earendil-works/pi-coding-agent": { "optional": true }
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --experimental-strip-types test/sanitize.test.ts && node test/e2e.mjs"
|
|
26
|
+
}
|
|
27
|
+
}
|