@trim21/personal-pi-extensions 0.0.349 → 0.0.352
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/package.json +1 -1
- package/src/claude-code/files.ts +1 -0
- package/src/lib/cli-args.ts +6 -64
- package/src/lib/lsp/client.ts +4 -2
- package/src/lib/lsp/lsp.ts +19 -1
- package/src/opencode/files.ts +17 -32
- package/src/talk/core.ts +65 -23
- package/src/talk/index.ts +12 -12
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -392,6 +392,7 @@ export function registerFileTools(
|
|
|
392
392
|
throwIfAborted(signal);
|
|
393
393
|
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
394
394
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
395
|
+
signal,
|
|
395
396
|
});
|
|
396
397
|
return [
|
|
397
398
|
`The file ${filePath} has been updated successfully.`,
|
package/src/lib/cli-args.ts
CHANGED
|
@@ -1,35 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Shell-like tokenizer for `/command` handlers.
|
|
3
3
|
*
|
|
4
4
|
* The pi command API passes handlers a raw string (current versions) or a
|
|
5
|
-
* token array (newer ones);
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `\"` / `\\` escapes, and a backslash outside quotes escapes the next
|
|
11
|
-
* character. Unlike bash, empty tokens are dropped and an unterminated
|
|
12
|
-
* quote raises a SyntaxError.
|
|
13
|
-
*
|
|
14
|
-
* Flags (only long form, `--name`):
|
|
15
|
-
* --name value flag "name" = "value" (value may not start with `--`)
|
|
16
|
-
* --name=value same
|
|
17
|
-
* --flag boolean flag = true
|
|
18
|
-
* -- everything after is a positional
|
|
5
|
+
* token array (newer ones); `shlexSplit` turns the raw string into tokens:
|
|
6
|
+
* whitespace separates tokens, single quotes preserve everything literally,
|
|
7
|
+
* double quotes allow `\"` / `\\` escapes, and a backslash outside quotes
|
|
8
|
+
* escapes the next character. Unlike bash, empty tokens are dropped and an
|
|
9
|
+
* unterminated quote raises a SyntaxError.
|
|
19
10
|
*/
|
|
20
11
|
|
|
21
|
-
export interface ParsedArgs {
|
|
22
|
-
/** Non-flag arguments, in order. */
|
|
23
|
-
positionals: string[];
|
|
24
|
-
/** `--name value` / `--name=value` → string; `--flag` → true. */
|
|
25
|
-
flags: Record<string, string | boolean>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Does this token look like a long flag (`--x`, but not the bare `--`)? */
|
|
29
|
-
function isFlagToken(token: string): boolean {
|
|
30
|
-
return token.startsWith("--") && token !== "--";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
12
|
/** Shell-like tokenizer for a raw command line. Empty tokens are dropped. */
|
|
34
13
|
export function shlexSplit(raw: string): string[] {
|
|
35
14
|
const tokens: string[] = [];
|
|
@@ -77,40 +56,3 @@ export function shlexSplit(raw: string): string[] {
|
|
|
77
56
|
if (cur) tokens.push(cur);
|
|
78
57
|
return tokens;
|
|
79
58
|
}
|
|
80
|
-
|
|
81
|
-
export function parseArgs(input: string | string[]): ParsedArgs {
|
|
82
|
-
const tokens = Array.isArray(input) ? input : shlexSplit(input);
|
|
83
|
-
const positionals: string[] = [];
|
|
84
|
-
const flags: Record<string, string | boolean> = {};
|
|
85
|
-
let positionalOnly = false;
|
|
86
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
87
|
-
const token = tokens[i];
|
|
88
|
-
if (positionalOnly) {
|
|
89
|
-
positionals.push(token);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (token === "--") {
|
|
93
|
-
positionalOnly = true;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
const m = /^--([a-zA-Z0-9][a-zA-Z0-9-]*)(?:=(.*))?$/.exec(token);
|
|
97
|
-
if (!m) {
|
|
98
|
-
positionals.push(token);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
const name = m[1];
|
|
102
|
-
if (m[2] !== undefined) {
|
|
103
|
-
flags[name] = m[2];
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
// `--name value` form: consume the next token unless it is itself a flag.
|
|
107
|
-
const next = tokens[i + 1];
|
|
108
|
-
if (next !== undefined && !isFlagToken(next)) {
|
|
109
|
-
flags[name] = next;
|
|
110
|
-
i++;
|
|
111
|
-
} else {
|
|
112
|
-
flags[name] = true;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return { positionals, flags };
|
|
116
|
-
}
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -642,8 +642,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
642
642
|
|
|
643
643
|
const document = files[resolvedPath];
|
|
644
644
|
if (document !== undefined) {
|
|
645
|
-
// didChange
|
|
646
|
-
//
|
|
645
|
+
// didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
|
|
646
|
+
// 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
|
|
647
|
+
pushDiagnostics.delete(resolvedPath);
|
|
648
|
+
pullDiagnostics.delete(resolvedPath);
|
|
647
649
|
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
648
650
|
changes: [{ uri, type: FILE_CHANGE_CHANGED }],
|
|
649
651
|
});
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -186,6 +186,20 @@ interface LspState {
|
|
|
186
186
|
|
|
187
187
|
export interface LspRequestOptions {
|
|
188
188
|
notify?: ExtensionUIContext["notify"];
|
|
189
|
+
/** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
|
|
190
|
+
signal?: AbortSignal;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 让 promise 在 signal 中止时提前结算;用于中断 LSP 诊断等待。 */
|
|
194
|
+
async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<void> {
|
|
195
|
+
if (!signal || signal.aborted) {
|
|
196
|
+
await promise;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const abort = new Promise<void>((resolve) => {
|
|
200
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
201
|
+
});
|
|
202
|
+
await Promise.race([promise, abort]);
|
|
189
203
|
}
|
|
190
204
|
|
|
191
205
|
export interface LspService {
|
|
@@ -333,7 +347,10 @@ export function createLspService(
|
|
|
333
347
|
const after = Date.now();
|
|
334
348
|
const version = await client.notify.open({ path: file });
|
|
335
349
|
if (!diagnostics) return;
|
|
336
|
-
await
|
|
350
|
+
await abortable(
|
|
351
|
+
client.waitForDiagnostics({ path: file, version, mode: diagnostics, after }),
|
|
352
|
+
options?.signal,
|
|
353
|
+
);
|
|
337
354
|
}),
|
|
338
355
|
).catch(() => {
|
|
339
356
|
// 诊断等待失败不影响写操作本身
|
|
@@ -360,6 +377,7 @@ export function createLspService(
|
|
|
360
377
|
cwd: string,
|
|
361
378
|
options?: LspRequestOptions,
|
|
362
379
|
): Promise<string> {
|
|
380
|
+
if (options?.signal?.aborted) return "";
|
|
363
381
|
await touchFile(file, cwd, "document", options);
|
|
364
382
|
const all = await diagnostics();
|
|
365
383
|
const normalized = normalize(file);
|
package/src/opencode/files.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { constants } from "node:fs";
|
|
21
21
|
import { access, mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
22
|
-
import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
|
|
22
|
+
import { basename, dirname, extname, isAbsolute, resolve as resolvePath, sep } from "node:path";
|
|
23
23
|
|
|
24
24
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
25
25
|
import {
|
|
@@ -154,9 +154,7 @@ async function detectImageMimeTypeFromFile(filePath: string): Promise<string | n
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function isBinaryExtension(filePath: string): boolean {
|
|
157
|
-
|
|
158
|
-
if (dotIndex === -1) return false;
|
|
159
|
-
return BINARY_EXTENSIONS.has(filePath.slice(dotIndex).toLowerCase());
|
|
157
|
+
return BINARY_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
160
158
|
}
|
|
161
159
|
|
|
162
160
|
function isBinaryFileBySample(sample: Uint8Array): boolean {
|
|
@@ -308,9 +306,7 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
308
306
|
),
|
|
309
307
|
}),
|
|
310
308
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
311
|
-
|
|
312
|
-
throw new Error("Operation aborted");
|
|
313
|
-
}
|
|
309
|
+
signal?.throwIfAborted();
|
|
314
310
|
|
|
315
311
|
const { filePath: rawPath, offset, limit } = params;
|
|
316
312
|
|
|
@@ -502,14 +498,10 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
502
498
|
change: { oldText: oldString, newText: newString, replaceAll },
|
|
503
499
|
});
|
|
504
500
|
|
|
505
|
-
const throwIfAborted = (): void => {
|
|
506
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
507
|
-
};
|
|
508
|
-
|
|
509
501
|
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
510
502
|
absolutePath,
|
|
511
503
|
async () => {
|
|
512
|
-
throwIfAborted();
|
|
504
|
+
signal?.throwIfAborted();
|
|
513
505
|
|
|
514
506
|
// opencode: 前置校验,先于空 oldString 分支
|
|
515
507
|
if (oldString === newString) {
|
|
@@ -524,7 +516,6 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
524
516
|
} catch {
|
|
525
517
|
exists = false;
|
|
526
518
|
}
|
|
527
|
-
throwIfAborted();
|
|
528
519
|
if (exists) {
|
|
529
520
|
throw new Error(
|
|
530
521
|
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
|
|
@@ -532,10 +523,11 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
532
523
|
}
|
|
533
524
|
// opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
|
|
534
525
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
535
|
-
throwIfAborted();
|
|
526
|
+
signal?.throwIfAborted();
|
|
536
527
|
await writeFile(absolutePath, newString, "utf8");
|
|
537
|
-
|
|
538
|
-
|
|
528
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
529
|
+
signal,
|
|
530
|
+
});
|
|
539
531
|
return [
|
|
540
532
|
"Edit applied successfully.",
|
|
541
533
|
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
@@ -546,18 +538,15 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
546
538
|
try {
|
|
547
539
|
await access(absolutePath, constants.R_OK | constants.W_OK);
|
|
548
540
|
} catch (error: unknown) {
|
|
549
|
-
throwIfAborted();
|
|
550
541
|
const msg =
|
|
551
542
|
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
552
543
|
? `Error code: ${error.code}`
|
|
553
544
|
: String(error);
|
|
554
545
|
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
555
546
|
}
|
|
556
|
-
throwIfAborted();
|
|
557
547
|
|
|
558
548
|
const buffer = await readFile(absolutePath);
|
|
559
549
|
const rawContent = buffer.toString("utf8");
|
|
560
|
-
throwIfAborted();
|
|
561
550
|
|
|
562
551
|
// Strip BOM then normalize line endings to LF.
|
|
563
552
|
// The opencode replacers split on \n and expect only LF.
|
|
@@ -566,16 +555,16 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
566
555
|
const normalizedContent = normalizeToLF(content);
|
|
567
556
|
|
|
568
557
|
const newContent = replace(normalizedContent, oldString, newString, replaceAll);
|
|
569
|
-
throwIfAborted();
|
|
558
|
+
signal?.throwIfAborted();
|
|
570
559
|
|
|
571
560
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
572
561
|
await writeFile(absolutePath, finalContent, "utf8");
|
|
573
|
-
throwIfAborted();
|
|
574
562
|
|
|
575
563
|
const diffResult = generateDiffString(normalizedContent, newContent);
|
|
576
564
|
const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
|
|
577
|
-
|
|
578
|
-
|
|
565
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
566
|
+
signal,
|
|
567
|
+
});
|
|
579
568
|
return [
|
|
580
569
|
"Edit applied successfully.",
|
|
581
570
|
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
@@ -640,14 +629,10 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
640
629
|
});
|
|
641
630
|
const dir = dirname(absolutePath);
|
|
642
631
|
|
|
643
|
-
const throwIfAborted = () => {
|
|
644
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
645
|
-
};
|
|
646
|
-
|
|
647
632
|
const [message, details, diagnosticText] = await withFileMutationQueue(
|
|
648
633
|
absolutePath,
|
|
649
634
|
async () => {
|
|
650
|
-
throwIfAborted();
|
|
635
|
+
signal?.throwIfAborted();
|
|
651
636
|
|
|
652
637
|
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
653
638
|
// 否则用新内容自带的 BOM
|
|
@@ -664,14 +649,14 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
664
649
|
} catch {
|
|
665
650
|
// 文件不存在:无旧 BOM
|
|
666
651
|
}
|
|
667
|
-
throwIfAborted();
|
|
668
652
|
const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
|
|
669
653
|
|
|
670
654
|
await mkdir(dir, { recursive: true });
|
|
671
|
-
throwIfAborted();
|
|
655
|
+
signal?.throwIfAborted();
|
|
672
656
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
673
|
-
|
|
674
|
-
|
|
657
|
+
const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
658
|
+
signal,
|
|
659
|
+
});
|
|
675
660
|
|
|
676
661
|
return ["Wrote file successfully.", undefined, diagnosticText] as const;
|
|
677
662
|
},
|
package/src/talk/core.ts
CHANGED
|
@@ -65,6 +65,12 @@ export interface TalkCoreEvents {
|
|
|
65
65
|
deliver(letter: Letter): boolean | Promise<boolean>;
|
|
66
66
|
/** Surface a notification (e.g. a presence transition) without waking a busy agent. */
|
|
67
67
|
notify(content: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* The agent's talk identity (agentId) was committed to a group by join or
|
|
70
|
+
* leave. The adapter persists it onto the session branch so a fork/resume
|
|
71
|
+
* of this session keeps the same identity.
|
|
72
|
+
*/
|
|
73
|
+
identityChange?(agentId: string): void;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
export interface TalkCoreOptions {
|
|
@@ -79,6 +85,35 @@ const DELIVERY_BACKOFF_MS = 5000;
|
|
|
79
85
|
const INITIAL_DRAIN_DELAY_MS = 1200;
|
|
80
86
|
const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* custom entry type that pins an agent's talk identity to the session branch.
|
|
90
|
+
* join/leave commit the agentId here; fork/branch/resume copy the entry, and
|
|
91
|
+
* restoreTalkAgentId recovers the identity on the next session_start.
|
|
92
|
+
*/
|
|
93
|
+
export const TALK_JOIN_ENTRY_TYPE = "talk:join";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Recover the talk identity (agentId) pinned to a session branch by its most
|
|
97
|
+
* recent join/leave record, or undefined when the branch never joined a group
|
|
98
|
+
* (a fresh session or one rewound before its join). Mirrors how the file tools
|
|
99
|
+
* rebuild their reads state from the current branch's history.
|
|
100
|
+
*/
|
|
101
|
+
export function restoreTalkAgentId(branchEntries: readonly unknown[]): string | undefined {
|
|
102
|
+
let agentId: string | undefined;
|
|
103
|
+
for (const entry of branchEntries) {
|
|
104
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
105
|
+
const { type, customType, data } = entry as {
|
|
106
|
+
type?: unknown;
|
|
107
|
+
customType?: unknown;
|
|
108
|
+
data?: unknown;
|
|
109
|
+
};
|
|
110
|
+
if (type !== "custom" || customType !== TALK_JOIN_ENTRY_TYPE) continue;
|
|
111
|
+
const recorded = (data as { agentId?: unknown } | undefined)?.agentId;
|
|
112
|
+
if (typeof recorded === "string" && recorded.length > 0) agentId = recorded;
|
|
113
|
+
}
|
|
114
|
+
return agentId;
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
/**
|
|
83
118
|
* Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
|
|
84
119
|
* the two ask letters are fixed values inside the letters, so both sides
|
|
@@ -528,31 +563,36 @@ export class TalkCore {
|
|
|
528
563
|
}
|
|
529
564
|
const nameNote = agentName === undefined ? "" : ` You are visible as "${agentName}".`;
|
|
530
565
|
const existing = await readGroup(this.storage, name);
|
|
566
|
+
let text: string;
|
|
531
567
|
if (existing?.members.includes(self.agentId)) {
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
568
|
+
text = `Already in group ${name} (${existing.members.length} member(s)). Members: ${await this.groupMemberNames(existing.members)}.${nameNote}`;
|
|
569
|
+
} else {
|
|
570
|
+
await this.leaveCurrentGroup();
|
|
571
|
+
if (existing) {
|
|
572
|
+
await writeGroup(this.storage, {
|
|
573
|
+
...existing,
|
|
574
|
+
members: [...existing.members, self.agentId],
|
|
575
|
+
updatedAt: this.now(),
|
|
576
|
+
});
|
|
577
|
+
text = `Joined group ${name} (${
|
|
578
|
+
existing.members.length + 1
|
|
579
|
+
} member(s)). Members: ${await this.groupMemberNames([
|
|
580
|
+
...existing.members,
|
|
581
|
+
self.agentId,
|
|
582
|
+
])}. You now see only co-members.${nameNote}`;
|
|
583
|
+
} else {
|
|
584
|
+
const now = this.now();
|
|
585
|
+
await writeGroup(this.storage, {
|
|
586
|
+
id: name,
|
|
587
|
+
members: [self.agentId],
|
|
588
|
+
createdAt: now,
|
|
589
|
+
updatedAt: now,
|
|
590
|
+
});
|
|
591
|
+
text = `Created group ${name}. Members: ${await this.groupMemberNames([self.agentId])}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
|
|
592
|
+
}
|
|
547
593
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
id: name,
|
|
551
|
-
members: [self.agentId],
|
|
552
|
-
createdAt: now,
|
|
553
|
-
updatedAt: now,
|
|
554
|
-
});
|
|
555
|
-
return `Created group ${name}. Members: ${await this.groupMemberNames([self.agentId])}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
|
|
594
|
+
this.events.identityChange?.(self.agentId);
|
|
595
|
+
return text;
|
|
556
596
|
}
|
|
557
597
|
|
|
558
598
|
/**
|
|
@@ -574,9 +614,11 @@ export class TalkCore {
|
|
|
574
614
|
const others = group.members.filter((m) => m !== self.agentId);
|
|
575
615
|
if (others.length === 0) {
|
|
576
616
|
await deleteGroup(this.storage, group.id);
|
|
617
|
+
this.events.identityChange?.(self.agentId);
|
|
577
618
|
return `Left group ${group.id} (deleted — it was empty).`;
|
|
578
619
|
}
|
|
579
620
|
await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
|
|
621
|
+
this.events.identityChange?.(self.agentId);
|
|
580
622
|
return `Left group ${group.id} (${others.length} member(s) remain).`;
|
|
581
623
|
}
|
|
582
624
|
|
package/src/talk/index.ts
CHANGED
|
@@ -24,8 +24,8 @@ import { type TObject, Type } from "typebox";
|
|
|
24
24
|
|
|
25
25
|
import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
26
26
|
import { resolveHomePath } from "../lib/path.js";
|
|
27
|
-
import { TalkCore } from "./core.js";
|
|
28
|
-
import { formatDelivery } from "./format.js";
|
|
27
|
+
import { restoreTalkAgentId, TALK_JOIN_ENTRY_TYPE, TalkCore } from "./core.js";
|
|
28
|
+
import { age, formatDelivery } from "./format.js";
|
|
29
29
|
import type { Letter } from "./mailbox.js";
|
|
30
30
|
import { type AgentRecord, deriveAddr } from "./registry.js";
|
|
31
31
|
import { SqliteTalkStorage } from "./storage.js";
|
|
@@ -68,14 +68,6 @@ function shortCwd(cwd: string): string {
|
|
|
68
68
|
return sanitizeTerminal(display);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function relativeTime(ts: number, now: number = Date.now()): string {
|
|
72
|
-
const s = Math.max(0, Math.round((now - ts) / 1000));
|
|
73
|
-
if (s < 60) return `${s}s ago`;
|
|
74
|
-
const m = Math.round(s / 60);
|
|
75
|
-
if (m < 60) return `${m}m ago`;
|
|
76
|
-
return `${Math.round(m / 60)}h ago`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
71
|
/** Presentation metadata for a delivery. `details` is never sent to the LLM. */
|
|
80
72
|
interface DeliveryDetails {
|
|
81
73
|
id: string;
|
|
@@ -150,6 +142,11 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
150
142
|
// in LLM context.
|
|
151
143
|
pi.appendEntry(NOTIFY_TYPE, content);
|
|
152
144
|
},
|
|
145
|
+
identityChange(agentId) {
|
|
146
|
+
// Pin the identity to the session branch so a fork/resume of this
|
|
147
|
+
// session keeps the same talk address and group membership.
|
|
148
|
+
pi.appendEntry(TALK_JOIN_ENTRY_TYPE, { agentId, ts: Date.now() });
|
|
149
|
+
},
|
|
153
150
|
},
|
|
154
151
|
});
|
|
155
152
|
|
|
@@ -174,9 +171,12 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
174
171
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
175
172
|
|
|
176
173
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
177
|
-
const agentId = ctx.sessionManager.getSessionId();
|
|
178
174
|
const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
|
|
179
175
|
const now = Date.now();
|
|
176
|
+
// A fork/branch/resume of a session that joined a group keeps that talk
|
|
177
|
+
// identity (agentId); a fresh session gets the new session id.
|
|
178
|
+
const agentId =
|
|
179
|
+
restoreTalkAgentId(ctx.sessionManager.getBranch()) ?? ctx.sessionManager.getSessionId();
|
|
180
180
|
self = {
|
|
181
181
|
addr: deriveAddr(cwd, agentId),
|
|
182
182
|
agentId,
|
|
@@ -475,7 +475,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
475
475
|
const idTail = d.id.slice(-8);
|
|
476
476
|
const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
|
|
477
477
|
const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
|
|
478
|
-
const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${
|
|
478
|
+
const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${age(d.ts)}`);
|
|
479
479
|
const out = [header, sanitizeTerminal(d.body), "", footer];
|
|
480
480
|
// plain 组件:不引入 pi-tui,直接输出带背景色的文本行
|
|
481
481
|
const text = out.join("\n");
|