mitsupi 1.0.0 → 1.0.2

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.
@@ -1,548 +0,0 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import { StringEnum } from "@mariozechner/pi-ai";
3
- import { Type } from "@sinclair/typebox";
4
- import path from "node:path";
5
- import fs from "node:fs/promises";
6
- import { existsSync } from "node:fs";
7
- import crypto from "node:crypto";
8
-
9
- const ISSUE_DIR_NAME = ".pi/issues";
10
- const LOCK_TTL_MS = 30 * 60 * 1000;
11
-
12
- interface IssueFrontMatter {
13
- id: string;
14
- title: string;
15
- tags: string[];
16
- status: string;
17
- created_at: string;
18
- }
19
-
20
- interface IssueRecord extends IssueFrontMatter {
21
- body: string;
22
- }
23
-
24
- interface LockInfo {
25
- id: string;
26
- pid: number;
27
- session?: string | null;
28
- created_at: string;
29
- }
30
-
31
- const IssueParams = Type.Object({
32
- action: StringEnum(["list", "get", "create", "update", "append"] as const),
33
- id: Type.Optional(Type.String({ description: "Issue id (filename)" })),
34
- title: Type.Optional(Type.String({ description: "Issue title" })),
35
- status: Type.Optional(Type.String({ description: "Issue status" })),
36
- tags: Type.Optional(Type.Array(Type.String({ description: "Issue tag" }))),
37
- body: Type.Optional(Type.String({ description: "Issue body or append text" })),
38
- });
39
-
40
- type IssueAction = "list" | "get" | "create" | "update" | "append";
41
-
42
- function getIssuesDir(cwd: string): string {
43
- return path.resolve(cwd, ISSUE_DIR_NAME);
44
- }
45
-
46
- function getIssuePath(issuesDir: string, id: string): string {
47
- return path.join(issuesDir, `${id}.md`);
48
- }
49
-
50
- function getLockPath(issuesDir: string, id: string): string {
51
- return path.join(issuesDir, `${id}.lock`);
52
- }
53
-
54
- function stripQuotes(value: string): string {
55
- const trimmed = value.trim();
56
- if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
57
- return trimmed.slice(1, -1);
58
- }
59
- return trimmed;
60
- }
61
-
62
- function parseTagsInline(value: string): string[] {
63
- const inner = value.trim().slice(1, -1);
64
- if (!inner.trim()) return [];
65
- return inner
66
- .split(",")
67
- .map((item) => stripQuotes(item))
68
- .map((item) => item.trim())
69
- .filter(Boolean);
70
- }
71
-
72
- function parseFrontMatter(text: string, idFallback: string): IssueFrontMatter {
73
- const data: IssueFrontMatter = {
74
- id: idFallback,
75
- title: "",
76
- tags: [],
77
- status: "open",
78
- created_at: "",
79
- };
80
-
81
- let currentKey: string | null = null;
82
- for (const rawLine of text.split(/\r?\n/)) {
83
- const line = rawLine.trim();
84
- if (!line) continue;
85
-
86
- const listMatch = currentKey === "tags" ? line.match(/^-\s*(.+)$/) : null;
87
- if (listMatch) {
88
- data.tags.push(stripQuotes(listMatch[1]));
89
- continue;
90
- }
91
-
92
- const match = line.match(/^(?<key>[a-zA-Z0-9_]+):\s*(?<value>.*)$/);
93
- if (!match?.groups) continue;
94
-
95
- const key = match.groups.key;
96
- const value = match.groups.value ?? "";
97
- currentKey = null;
98
-
99
- if (key === "tags") {
100
- if (!value) {
101
- currentKey = "tags";
102
- continue;
103
- }
104
- if (value.startsWith("[") && value.endsWith("]")) {
105
- data.tags = parseTagsInline(value);
106
- continue;
107
- }
108
- data.tags = [stripQuotes(value)].filter(Boolean);
109
- continue;
110
- }
111
-
112
- switch (key) {
113
- case "id":
114
- data.id = stripQuotes(value) || data.id;
115
- break;
116
- case "title":
117
- data.title = stripQuotes(value);
118
- break;
119
- case "status":
120
- data.status = stripQuotes(value) || data.status;
121
- break;
122
- case "created_at":
123
- data.created_at = stripQuotes(value);
124
- break;
125
- default:
126
- break;
127
- }
128
- }
129
-
130
- return data;
131
- }
132
-
133
- function splitFrontMatter(content: string): { frontMatter: string; body: string } {
134
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
135
- if (!match) {
136
- return { frontMatter: "", body: content };
137
- }
138
- const frontMatter = match[1] ?? "";
139
- const body = content.slice(match[0].length);
140
- return { frontMatter, body };
141
- }
142
-
143
- function parseIssueContent(content: string, idFallback: string): IssueRecord {
144
- const { frontMatter, body } = splitFrontMatter(content);
145
- const parsed = parseFrontMatter(frontMatter, idFallback);
146
- return {
147
- id: idFallback,
148
- title: parsed.title,
149
- tags: parsed.tags ?? [],
150
- status: parsed.status,
151
- created_at: parsed.created_at,
152
- body: body ?? "",
153
- };
154
- }
155
-
156
- function escapeYaml(value: string): string {
157
- return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
158
- }
159
-
160
- function serializeIssue(issue: IssueRecord): string {
161
- const tags = issue.tags ?? [];
162
- const lines = [
163
- "---",
164
- `id: \"${escapeYaml(issue.id)}\"`,
165
- `title: \"${escapeYaml(issue.title)}\"`,
166
- "tags:",
167
- ...tags.map((tag) => ` - \"${escapeYaml(tag)}\"`),
168
- `status: \"${escapeYaml(issue.status)}\"`,
169
- `created_at: \"${escapeYaml(issue.created_at)}\"`,
170
- "---",
171
- "",
172
- ];
173
-
174
- const body = issue.body ?? "";
175
- const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, "");
176
- return `${lines.join("\n")}${trimmedBody ? `${trimmedBody}\n` : ""}`;
177
- }
178
-
179
- async function ensureIssuesDir(issuesDir: string) {
180
- await fs.mkdir(issuesDir, { recursive: true });
181
- }
182
-
183
- async function readIssueFile(filePath: string, idFallback: string): Promise<IssueRecord> {
184
- const content = await fs.readFile(filePath, "utf8");
185
- return parseIssueContent(content, idFallback);
186
- }
187
-
188
- async function writeIssueFile(filePath: string, issue: IssueRecord) {
189
- await fs.writeFile(filePath, serializeIssue(issue), "utf8");
190
- }
191
-
192
- async function generateIssueId(issuesDir: string): Promise<string> {
193
- for (let attempt = 0; attempt < 10; attempt += 1) {
194
- const id = crypto.randomBytes(4).toString("hex");
195
- const issuePath = getIssuePath(issuesDir, id);
196
- if (!existsSync(issuePath)) return id;
197
- }
198
- throw new Error("Failed to generate unique issue id");
199
- }
200
-
201
- async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
202
- try {
203
- const raw = await fs.readFile(lockPath, "utf8");
204
- return JSON.parse(raw) as LockInfo;
205
- } catch {
206
- return null;
207
- }
208
- }
209
-
210
- async function acquireLock(
211
- issuesDir: string,
212
- id: string,
213
- ctx: ExtensionContext,
214
- ): Promise<(() => Promise<void>) | { error: string }> {
215
- const lockPath = getLockPath(issuesDir, id);
216
- const now = Date.now();
217
- const session = ctx.sessionManager.getSessionFile();
218
-
219
- for (let attempt = 0; attempt < 2; attempt += 1) {
220
- try {
221
- const handle = await fs.open(lockPath, "wx");
222
- const info: LockInfo = {
223
- id,
224
- pid: process.pid,
225
- session,
226
- created_at: new Date(now).toISOString(),
227
- };
228
- await handle.writeFile(JSON.stringify(info, null, 2), "utf8");
229
- await handle.close();
230
- return async () => {
231
- try {
232
- await fs.unlink(lockPath);
233
- } catch {
234
- // ignore
235
- }
236
- };
237
- } catch (error: any) {
238
- if (error?.code !== "EEXIST") {
239
- return { error: `Failed to acquire lock: ${error?.message ?? "unknown error"}` };
240
- }
241
- const stats = await fs.stat(lockPath).catch(() => null);
242
- const lockAge = stats ? now - stats.mtimeMs : LOCK_TTL_MS + 1;
243
- if (lockAge <= LOCK_TTL_MS) {
244
- const info = await readLockInfo(lockPath);
245
- const owner = info?.session ? ` (session ${info.session})` : "";
246
- return { error: `Issue ${id} is locked${owner}. Try again later.` };
247
- }
248
- if (!ctx.hasUI) {
249
- return { error: `Issue ${id} lock is stale; rerun in interactive mode to steal it.` };
250
- }
251
- const ok = await ctx.ui.confirm("Issue locked", `Issue ${id} appears locked. Steal the lock?`);
252
- if (!ok) {
253
- return { error: `Issue ${id} remains locked.` };
254
- }
255
- await fs.unlink(lockPath).catch(() => undefined);
256
- }
257
- }
258
-
259
- return { error: `Failed to acquire lock for issue ${id}.` };
260
- }
261
-
262
- async function withIssueLock<T>(
263
- issuesDir: string,
264
- id: string,
265
- ctx: ExtensionContext,
266
- fn: () => Promise<T>,
267
- ): Promise<T | { error: string }> {
268
- const lock = await acquireLock(issuesDir, id, ctx);
269
- if (typeof lock === "object" && "error" in lock) return lock;
270
- try {
271
- return await fn();
272
- } finally {
273
- await lock();
274
- }
275
- }
276
-
277
- async function listIssues(issuesDir: string): Promise<IssueFrontMatter[]> {
278
- let entries: string[] = [];
279
- try {
280
- entries = await fs.readdir(issuesDir);
281
- } catch {
282
- return [];
283
- }
284
-
285
- const issues: IssueFrontMatter[] = [];
286
- for (const entry of entries) {
287
- if (!entry.endsWith(".md")) continue;
288
- const id = entry.slice(0, -3);
289
- const filePath = path.join(issuesDir, entry);
290
- try {
291
- const content = await fs.readFile(filePath, "utf8");
292
- const { frontMatter } = splitFrontMatter(content);
293
- const parsed = parseFrontMatter(frontMatter, id);
294
- issues.push({
295
- id,
296
- title: parsed.title,
297
- tags: parsed.tags ?? [],
298
- status: parsed.status,
299
- created_at: parsed.created_at,
300
- });
301
- } catch {
302
- // ignore unreadable issue
303
- }
304
- }
305
-
306
- issues.sort((a, b) => a.created_at.localeCompare(b.created_at));
307
- return issues;
308
- }
309
-
310
- function formatIssueList(issues: IssueFrontMatter[]): string {
311
- if (!issues.length) return "No issues.";
312
- return issues
313
- .map((issue) => {
314
- const tagText = issue.tags.length ? ` [${issue.tags.join(", ")}]` : "";
315
- return `#${issue.id} (${issue.status}) ${issue.title}${tagText}`;
316
- })
317
- .join("\n");
318
- }
319
-
320
- async function ensureIssueExists(filePath: string, id: string): Promise<IssueRecord | null> {
321
- if (!existsSync(filePath)) return null;
322
- return readIssueFile(filePath, id);
323
- }
324
-
325
- async function appendIssueBody(filePath: string, issue: IssueRecord, text: string): Promise<IssueRecord> {
326
- const spacer = issue.body.trim().length ? "\n\n" : "";
327
- issue.body = `${issue.body.replace(/\s+$/, "")}${spacer}${text.trim()}\n`;
328
- await writeIssueFile(filePath, issue);
329
- return issue;
330
- }
331
-
332
- export default function issuesExtension(pi: ExtensionAPI) {
333
- pi.registerTool({
334
- name: "issue",
335
- label: "Issue",
336
- description: "Manage file-based issues in .pi/issues (list, get, create, update, append)",
337
- parameters: IssueParams,
338
-
339
- async execute(_toolCallId, params, _onUpdate, ctx) {
340
- const issuesDir = getIssuesDir(ctx.cwd);
341
- const action: IssueAction = params.action;
342
-
343
- switch (action) {
344
- case "list": {
345
- const issues = await listIssues(issuesDir);
346
- return {
347
- content: [{ type: "text", text: formatIssueList(issues) }],
348
- details: { issues },
349
- };
350
- }
351
-
352
- case "get": {
353
- if (!params.id) {
354
- return {
355
- content: [{ type: "text", text: "Error: id required" }],
356
- details: { error: "id required" },
357
- };
358
- }
359
- const filePath = getIssuePath(issuesDir, params.id);
360
- const issue = await ensureIssueExists(filePath, params.id);
361
- if (!issue) {
362
- return {
363
- content: [{ type: "text", text: `Issue ${params.id} not found` }],
364
- details: { error: "not found" },
365
- };
366
- }
367
- return {
368
- content: [{ type: "text", text: serializeIssue(issue) }],
369
- details: { issue },
370
- };
371
- }
372
-
373
- case "create": {
374
- if (!params.title) {
375
- return {
376
- content: [{ type: "text", text: "Error: title required" }],
377
- details: { error: "title required" },
378
- };
379
- }
380
- await ensureIssuesDir(issuesDir);
381
- const id = await generateIssueId(issuesDir);
382
- const filePath = getIssuePath(issuesDir, id);
383
- const issue: IssueRecord = {
384
- id,
385
- title: params.title,
386
- tags: params.tags ?? [],
387
- status: params.status ?? "open",
388
- created_at: new Date().toISOString(),
389
- body: params.body ?? "",
390
- };
391
-
392
- const result = await withIssueLock(issuesDir, id, ctx, async () => {
393
- await writeIssueFile(filePath, issue);
394
- return issue;
395
- });
396
-
397
- if (typeof result === "object" && "error" in result) {
398
- return {
399
- content: [{ type: "text", text: result.error }],
400
- details: { error: result.error },
401
- };
402
- }
403
-
404
- return {
405
- content: [{ type: "text", text: `Created issue ${id}` }],
406
- details: { issue },
407
- };
408
- }
409
-
410
- case "update": {
411
- if (!params.id) {
412
- return {
413
- content: [{ type: "text", text: "Error: id required" }],
414
- details: { error: "id required" },
415
- };
416
- }
417
- const filePath = getIssuePath(issuesDir, params.id);
418
- if (!existsSync(filePath)) {
419
- return {
420
- content: [{ type: "text", text: `Issue ${params.id} not found` }],
421
- details: { error: "not found" },
422
- };
423
- }
424
- const result = await withIssueLock(issuesDir, params.id, ctx, async () => {
425
- const existing = await ensureIssueExists(filePath, params.id);
426
- if (!existing) return { error: `Issue ${params.id} not found` } as const;
427
-
428
- existing.id = params.id;
429
- if (params.title !== undefined) existing.title = params.title;
430
- if (params.status !== undefined) existing.status = params.status;
431
- if (params.tags !== undefined) existing.tags = params.tags;
432
- if (!existing.created_at) existing.created_at = new Date().toISOString();
433
-
434
- await writeIssueFile(filePath, existing);
435
- return existing;
436
- });
437
-
438
- if (typeof result === "object" && "error" in result) {
439
- return {
440
- content: [{ type: "text", text: result.error }],
441
- details: { error: result.error },
442
- };
443
- }
444
-
445
- return {
446
- content: [{ type: "text", text: `Updated issue ${params.id}` }],
447
- details: { issue: result },
448
- };
449
- }
450
-
451
- case "append": {
452
- if (!params.id) {
453
- return {
454
- content: [{ type: "text", text: "Error: id required" }],
455
- details: { error: "id required" },
456
- };
457
- }
458
- if (!params.body) {
459
- return {
460
- content: [{ type: "text", text: "Error: body required" }],
461
- details: { error: "body required" },
462
- };
463
- }
464
- const filePath = getIssuePath(issuesDir, params.id);
465
- if (!existsSync(filePath)) {
466
- return {
467
- content: [{ type: "text", text: `Issue ${params.id} not found` }],
468
- details: { error: "not found" },
469
- };
470
- }
471
- const result = await withIssueLock(issuesDir, params.id, ctx, async () => {
472
- const existing = await ensureIssueExists(filePath, params.id);
473
- if (!existing) return { error: `Issue ${params.id} not found` } as const;
474
- const updated = await appendIssueBody(filePath, existing, params.body!);
475
- return updated;
476
- });
477
-
478
- if (typeof result === "object" && "error" in result) {
479
- return {
480
- content: [{ type: "text", text: result.error }],
481
- details: { error: result.error },
482
- };
483
- }
484
-
485
- return {
486
- content: [{ type: "text", text: `Appended to issue ${params.id}` }],
487
- details: { issue: result },
488
- };
489
- }
490
- }
491
- },
492
- });
493
-
494
- pi.registerCommand("issues", {
495
- description: "List issues from .pi/issues",
496
- handler: async (_args, ctx) => {
497
- const issuesDir = getIssuesDir(ctx.cwd);
498
- const issues = await listIssues(issuesDir);
499
- const text = formatIssueList(issues);
500
- if (ctx.hasUI) {
501
- ctx.ui.notify(text, "info");
502
- } else {
503
- console.log(text);
504
- }
505
- },
506
- });
507
-
508
- pi.registerCommand("issue-log", {
509
- description: "Append text to an issue body",
510
- handler: async (args, ctx) => {
511
- const id = (args ?? "").trim();
512
- if (!id) {
513
- ctx.ui.notify("Usage: /issue-log <id>", "error");
514
- return;
515
- }
516
- if (!ctx.hasUI) {
517
- ctx.ui.notify("/issue-log requires interactive mode", "error");
518
- return;
519
- }
520
-
521
- const issuesDir = getIssuesDir(ctx.cwd);
522
- const filePath = getIssuePath(issuesDir, id);
523
- if (!existsSync(filePath)) {
524
- ctx.ui.notify(`Issue ${id} not found`, "error");
525
- return;
526
- }
527
-
528
- const text = await ctx.ui.editor(`Append to issue ${id}:`, "");
529
- if (!text?.trim()) {
530
- ctx.ui.notify("No text provided", "warning");
531
- return;
532
- }
533
-
534
- const result = await withIssueLock(issuesDir, id, ctx, async () => {
535
- const existing = await ensureIssueExists(filePath, id);
536
- if (!existing) return { error: `Issue ${id} not found` } as const;
537
- return appendIssueBody(filePath, existing, text);
538
- });
539
-
540
- if (typeof result === "object" && "error" in result) {
541
- ctx.ui.notify(result.error, "error");
542
- return;
543
- }
544
-
545
- ctx.ui.notify(`Appended to issue ${id}`, "info");
546
- },
547
- });
548
- }
@@ -1,81 +0,0 @@
1
- {
2
- "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
3
- "name": "armin",
4
- "vars": {
5
- "cyan": "#52b8c9",
6
- "blue": "#5a8ad0",
7
- "green": "#68c4b0",
8
- "red": "#c95a4a",
9
- "yellow": "#d9c878",
10
- "magenta": "#d84a88",
11
- "gray": "#7a8899",
12
- "dimGray": "#5a6a7a",
13
- "darkGray": "#3a4a5a",
14
- "accent": "#64b8c9",
15
- "foreground": "#a8b0c8",
16
- "background": "#111e2a",
17
- "userMsgBg": "#1a2836",
18
- "toolPendingBg": "#162230",
19
- "toolSuccessBg": "#16282a",
20
- "toolErrorBg": "#2a1e22"
21
- },
22
- "colors": {
23
- "accent": "accent",
24
- "border": "blue",
25
- "borderAccent": "cyan",
26
- "borderMuted": "darkGray",
27
- "success": "green",
28
- "error": "red",
29
- "warning": "yellow",
30
- "muted": "gray",
31
- "dim": "dimGray",
32
- "text": "",
33
- "thinkingText": "gray",
34
-
35
- "selectedBg": "darkGray",
36
- "userMessageBg": "userMsgBg",
37
- "userMessageText": "",
38
- "toolPendingBg": "toolPendingBg",
39
- "toolSuccessBg": "toolSuccessBg",
40
- "toolErrorBg": "toolErrorBg",
41
- "toolTitle": "",
42
- "toolOutput": "gray",
43
- "customMessageBg": "userMsgBg",
44
- "customMessageText": "",
45
- "customMessageLabel": "accent",
46
-
47
- "mdHeading": "yellow",
48
- "mdLink": "blue",
49
- "mdLinkUrl": "dimGray",
50
- "mdCode": "accent",
51
- "mdCodeBlock": "green",
52
- "mdCodeBlockBorder": "gray",
53
- "mdQuote": "gray",
54
- "mdQuoteBorder": "gray",
55
- "mdHr": "gray",
56
- "mdListBullet": "accent",
57
-
58
- "toolDiffAdded": "green",
59
- "toolDiffRemoved": "red",
60
- "toolDiffContext": "gray",
61
-
62
- "syntaxComment": "#5a7a6a",
63
- "syntaxKeyword": "blue",
64
- "syntaxFunction": "yellow",
65
- "syntaxVariable": "cyan",
66
- "syntaxString": "green",
67
- "syntaxNumber": "magenta",
68
- "syntaxType": "cyan",
69
- "syntaxOperator": "foreground",
70
- "syntaxPunctuation": "foreground",
71
-
72
- "thinkingOff": "darkGray",
73
- "thinkingMinimal": "#4a5a6a",
74
- "thinkingLow": "#4a6a8a",
75
- "thinkingMedium": "#5a8aaa",
76
- "thinkingHigh": "#8a6a9a",
77
- "thinkingXhigh": "#aa6aaa",
78
-
79
- "bashMode": "green"
80
- }
81
- }