shariq-pi-extensions 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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/docs/ARCHITECTURE.md +54 -0
  5. package/docs/DEVELOPMENT.md +88 -0
  6. package/docs/EXTENSIONS.md +89 -0
  7. package/extensions/antigravity-provider/LICENSE +21 -0
  8. package/extensions/antigravity-provider/README.md +23 -0
  9. package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +519 -0
  10. package/extensions/antigravity-provider/antigravity/index.ts +67 -0
  11. package/extensions/antigravity-provider/antigravity/models.ts +184 -0
  12. package/extensions/antigravity-provider/antigravity/oauth.ts +499 -0
  13. package/extensions/antigravity-provider/index.ts +3 -0
  14. package/extensions/ask-user/README.md +21 -0
  15. package/extensions/ask-user/index.ts +417 -0
  16. package/extensions/background-terminals/README.md +43 -0
  17. package/extensions/background-terminals/index.ts +400 -0
  18. package/extensions/background-terminals/src/manager.ts +480 -0
  19. package/extensions/background-terminals/src/output-buffer.ts +110 -0
  20. package/extensions/background-terminals/src/presentation.ts +74 -0
  21. package/extensions/background-terminals/src/types.ts +47 -0
  22. package/extensions/background-terminals/src/ui.ts +478 -0
  23. package/extensions/context-usage/LICENSE +21 -0
  24. package/extensions/context-usage/README.md +20 -0
  25. package/extensions/context-usage/index.ts +2006 -0
  26. package/extensions/context-usage/lib/ansi.ts +60 -0
  27. package/extensions/context-usage/lib/boundary.ts +27 -0
  28. package/extensions/context-usage/lib/chat.ts +190 -0
  29. package/extensions/context-usage/lib/config.ts +30 -0
  30. package/extensions/context-usage/lib/fmt.ts +59 -0
  31. package/extensions/context-usage/lib/style.ts +260 -0
  32. package/extensions/copy-all/README.md +15 -0
  33. package/extensions/copy-all/index.ts +72 -0
  34. package/extensions/factory-provider/API_KEYS.md +17 -0
  35. package/extensions/factory-provider/AUTH.md +15 -0
  36. package/extensions/factory-provider/README.md +90 -0
  37. package/extensions/factory-provider/REFRESH_MODELS.md +185 -0
  38. package/extensions/factory-provider/UPDATING.md +97 -0
  39. package/extensions/factory-provider/factory/api-keys.ts +407 -0
  40. package/extensions/factory-provider/factory/auth.ts +132 -0
  41. package/extensions/factory-provider/factory/constants.ts +15 -0
  42. package/extensions/factory-provider/factory/dashboard.ts +363 -0
  43. package/extensions/factory-provider/factory/droid.ts +84 -0
  44. package/extensions/factory-provider/factory/limits.ts +342 -0
  45. package/extensions/factory-provider/factory/models.ts +283 -0
  46. package/extensions/factory-provider/factory/responses.ts +331 -0
  47. package/extensions/factory-provider/factory/warm-theme.ts +53 -0
  48. package/extensions/factory-provider/factory/websocket.ts +130 -0
  49. package/extensions/factory-provider/index.ts +204 -0
  50. package/extensions/factory-provider/scripts/audit-reasoning-efforts.py +84 -0
  51. package/extensions/firecrawl-web/README.md +31 -0
  52. package/extensions/firecrawl-web/auth.ts +92 -0
  53. package/extensions/firecrawl-web/client.ts +238 -0
  54. package/extensions/firecrawl-web/index.ts +96 -0
  55. package/extensions/firecrawl-web/output.ts +125 -0
  56. package/extensions/git-info/README.md +25 -0
  57. package/extensions/git-info/index.ts +162 -0
  58. package/extensions/git-info/src/git.ts +206 -0
  59. package/extensions/git-info/src/ui.ts +291 -0
  60. package/extensions/goal/README.md +44 -0
  61. package/extensions/goal/constants.ts +4 -0
  62. package/extensions/goal/extension.ts +1064 -0
  63. package/extensions/goal/index.ts +2 -0
  64. package/extensions/goal/types.ts +56 -0
  65. package/extensions/goal/ui.ts +334 -0
  66. package/extensions/orchestration/README.md +58 -0
  67. package/extensions/orchestration/dashboard.ts +192 -0
  68. package/extensions/orchestration/engine.ts +706 -0
  69. package/extensions/orchestration/index.ts +243 -0
  70. package/extensions/orchestration/prompts.ts +124 -0
  71. package/extensions/orchestration/protocol.ts +116 -0
  72. package/extensions/orchestration/settings-ui.ts +41 -0
  73. package/extensions/orchestration/settings.ts +59 -0
  74. package/extensions/orchestration/storage.ts +49 -0
  75. package/extensions/orchestration/types.ts +105 -0
  76. package/extensions/performance-status/README.md +5 -0
  77. package/extensions/performance-status/index.ts +171 -0
  78. package/extensions/pi-memory/README.md +53 -0
  79. package/extensions/pi-memory/index.ts +326 -0
  80. package/extensions/pi-memory/src/config.ts +75 -0
  81. package/extensions/pi-memory/src/database.ts +617 -0
  82. package/extensions/pi-memory/src/extraction.ts +178 -0
  83. package/extensions/pi-memory/src/project.ts +80 -0
  84. package/extensions/pi-memory/src/projection.ts +59 -0
  85. package/extensions/pi-memory/src/redaction.ts +60 -0
  86. package/extensions/pi-memory/src/retrieval.ts +30 -0
  87. package/extensions/pi-memory/src/service.ts +194 -0
  88. package/extensions/pi-memory/src/session.ts +146 -0
  89. package/extensions/pi-memory/src/types.ts +82 -0
  90. package/extensions/shared/README.md +18 -0
  91. package/extensions/shared/activity-status.ts +31 -0
  92. package/extensions/shared/child-session.ts +150 -0
  93. package/extensions/shared/context-utilization.ts +47 -0
  94. package/extensions/shared/dashboard-state.ts +99 -0
  95. package/extensions/shared/tool-call-timeout.ts +104 -0
  96. package/extensions/shared/tui-dashboard.ts +139 -0
  97. package/extensions/shell-shortcuts/README.md +17 -0
  98. package/extensions/shell-shortcuts/features/exit-alias.ts +10 -0
  99. package/extensions/shell-shortcuts/index.ts +6 -0
  100. package/extensions/subagents/README.md +89 -0
  101. package/extensions/subagents/index.ts +1386 -0
  102. package/extensions/subagents/src/backend.ts +69 -0
  103. package/extensions/subagents/src/backends/pi.ts +781 -0
  104. package/extensions/subagents/src/catalog.ts +85 -0
  105. package/extensions/subagents/src/config.ts +241 -0
  106. package/extensions/subagents/src/context.ts +57 -0
  107. package/extensions/subagents/src/coordinator.ts +64 -0
  108. package/extensions/subagents/src/domain.ts +294 -0
  109. package/extensions/subagents/src/format.ts +74 -0
  110. package/extensions/subagents/src/manager.ts +813 -0
  111. package/extensions/subagents/src/prompt.ts +94 -0
  112. package/extensions/subagents/src/result-delivery.ts +20 -0
  113. package/extensions/subagents/src/runtime.ts +48 -0
  114. package/extensions/subagents/src/ui/takeover.ts +695 -0
  115. package/extensions/subagents/src/ui/transcript.ts +182 -0
  116. package/extensions/subagents/src/worktree.ts +175 -0
  117. package/extensions/web-fetch/README.md +13 -0
  118. package/extensions/web-fetch/index.ts +441 -0
  119. package/package.json +117 -0
  120. package/skills/background-terminals/SKILL.md +42 -0
  121. package/skills/orchestration/SKILL.md +33 -0
  122. package/skills/subagents/SKILL.md +89 -0
  123. package/themes/ember-warm-dark.json +85 -0
@@ -0,0 +1,146 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { CapturedSession, ProjectIdentity } from "./types.ts";
3
+ import { hasSensitiveToolArguments, redactSecrets, sanitizeToolArguments } from "./redaction.ts";
4
+
5
+ type EntryLike = {
6
+ id?: unknown;
7
+ type?: unknown;
8
+ summary?: unknown;
9
+ message?: {
10
+ role?: unknown;
11
+ content?: unknown;
12
+ toolName?: unknown;
13
+ toolCallId?: unknown;
14
+ isError?: unknown;
15
+ };
16
+ };
17
+
18
+ type ContentBlock = {
19
+ type?: unknown;
20
+ id?: unknown;
21
+ text?: unknown;
22
+ name?: unknown;
23
+ arguments?: unknown;
24
+ };
25
+
26
+ const MEMORY_ELIGIBLE_TOOLS = new Set([
27
+ "read", "write", "edit", "bash", "grep", "find", "ls",
28
+ "start_terminal", "read_terminal", "write_terminal", "stop_terminal",
29
+ "spawn_agent", "task", "wait_agent", "check_agent", "send_message", "reply_question",
30
+ ]);
31
+
32
+ export function isMemoryEligibleTool(toolName: string): boolean {
33
+ return MEMORY_ELIGIBLE_TOOLS.has(toolName);
34
+ }
35
+
36
+ function textContent(content: unknown): string {
37
+ if (typeof content === "string") return content;
38
+ if (!Array.isArray(content)) return "";
39
+ return content
40
+ .filter((block): block is ContentBlock => typeof block === "object" && block !== null)
41
+ .filter((block) => block.type === "text" && typeof block.text === "string")
42
+ .map((block) => String(block.text))
43
+ .join("\n");
44
+ }
45
+
46
+ function serializeEntry(entry: EntryLike, sensitiveToolCallIds: ReadonlySet<string>): string | undefined {
47
+ const id = typeof entry.id === "string" ? entry.id : "unknown";
48
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
49
+ return `[${id}] Compaction summary:\n${redactSecrets(entry.summary).slice(0, 8_000)}`;
50
+ }
51
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
52
+ return `[${id}] Branch summary:\n${redactSecrets(entry.summary).slice(0, 8_000)}`;
53
+ }
54
+ if (entry.type !== "message" || !entry.message) return undefined;
55
+
56
+ const role = entry.message.role;
57
+ if (role === "user") {
58
+ const text = redactSecrets(textContent(entry.message.content)).trim();
59
+ return text ? `[${id}] User:\n${text}` : undefined;
60
+ }
61
+ if (role === "assistant") {
62
+ const blocks = Array.isArray(entry.message.content) ? entry.message.content : [];
63
+ const parts: string[] = [];
64
+ const text = redactSecrets(textContent(blocks)).trim();
65
+ if (text) parts.push(text);
66
+ for (const rawBlock of blocks) {
67
+ if (typeof rawBlock !== "object" || rawBlock === null) continue;
68
+ const block = rawBlock as ContentBlock;
69
+ if (block.type === "toolCall" && typeof block.name === "string" && isMemoryEligibleTool(block.name)) {
70
+ parts.push(`Tool call ${block.name}: ${sanitizeToolArguments(block.name, block.arguments)}`);
71
+ }
72
+ }
73
+ return parts.length > 0 ? `[${id}] Assistant:\n${parts.join("\n")}` : undefined;
74
+ }
75
+ if (role === "toolResult") {
76
+ const toolCallId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : undefined;
77
+ if (toolCallId && sensitiveToolCallIds.has(toolCallId)) return undefined;
78
+ const toolName = typeof entry.message.toolName === "string" ? entry.message.toolName : "unknown";
79
+ if (!isMemoryEligibleTool(toolName)) return undefined;
80
+ const text = redactSecrets(textContent(entry.message.content)).trim().slice(0, 3_000);
81
+ if (!text) return undefined;
82
+ return `[${id}] Tool result ${toolName}${entry.message.isError ? " (error)" : ""}:\n${text}`;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ export function captureSession(
88
+ ctx: Pick<ExtensionContext, "sessionManager">,
89
+ project: ProjectIdentity,
90
+ checkpointLeafId: string | undefined,
91
+ maxCharacters: number,
92
+ ): CapturedSession | undefined {
93
+ const branch = ctx.sessionManager.getBranch() as EntryLike[];
94
+ const leaf = branch.at(-1);
95
+ const leafId = typeof leaf?.id === "string" ? leaf.id : undefined;
96
+ if (!leafId || leafId === checkpointLeafId) return undefined;
97
+
98
+ const sensitiveToolCallIds = new Set<string>();
99
+ for (const entry of branch) {
100
+ if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
101
+ for (const rawBlock of entry.message.content) {
102
+ if (!rawBlock || typeof rawBlock !== "object") continue;
103
+ const block = rawBlock as ContentBlock;
104
+ if (
105
+ block.type === "toolCall"
106
+ && typeof block.id === "string"
107
+ && typeof block.name === "string"
108
+ && hasSensitiveToolArguments(block.name, block.arguments)
109
+ ) {
110
+ sensitiveToolCallIds.add(block.id);
111
+ }
112
+ }
113
+ }
114
+
115
+ const checkpointIndex = checkpointLeafId ? branch.findIndex((entry) => entry.id === checkpointLeafId) : -1;
116
+ const firstNewIndex = checkpointIndex >= 0 ? checkpointIndex + 1 : 0;
117
+ const contextStart = Math.max(0, firstNewIndex - 4);
118
+ const selected = branch.slice(contextStart);
119
+ const newSerialized = branch.slice(firstNewIndex)
120
+ .map((entry) => ({ entry, text: serializeEntry(entry, sensitiveToolCallIds) }))
121
+ .filter((item): item is { entry: EntryLike & { id: string }; text: string } => typeof item.entry.id === "string" && Boolean(item.text));
122
+ if (newSerialized.length === 0) return undefined;
123
+ const entryIds = newSerialized.map((item) => item.entry.id);
124
+
125
+ const serialized = selected.map((entry) => serializeEntry(entry, sensitiveToolCallIds)).filter((value): value is string => Boolean(value));
126
+ if (serialized.length === 0) return undefined;
127
+ let transcript = serialized.join("\n\n");
128
+ if (transcript.length > maxCharacters) transcript = `[older content omitted]\n\n${transcript.slice(-maxCharacters)}`;
129
+
130
+ const queryText = redactSecrets(branch.slice(firstNewIndex)
131
+ .filter((entry) => entry.type === "message" && entry.message?.role === "user")
132
+ .map((entry) => textContent(entry.message?.content))
133
+ .join("\n"))
134
+ .slice(0, 8_000);
135
+
136
+ return {
137
+ sessionId: ctx.sessionManager.getSessionId(),
138
+ sessionFile: ctx.sessionManager.getSessionFile() ?? "ephemeral",
139
+ project,
140
+ leafId,
141
+ entryIds,
142
+ transcript,
143
+ queryText,
144
+ capturedAt: Date.now(),
145
+ };
146
+ }
@@ -0,0 +1,82 @@
1
+ export const MEMORY_KINDS = [
2
+ "preference",
3
+ "decision",
4
+ "convention",
5
+ "fact",
6
+ "solution",
7
+ "warning",
8
+ "reference",
9
+ ] as const;
10
+
11
+ export type MemoryKind = (typeof MEMORY_KINDS)[number];
12
+ export type MemoryScope = "global" | "project";
13
+ export type MemoryStatus = "active" | "superseded" | "deleted";
14
+ export type MemorySourceKind = "pi-session" | "manual" | "bootstrap-import";
15
+
16
+ export interface ProjectIdentity {
17
+ id: string;
18
+ identity: string;
19
+ rootPath: string;
20
+ displayName: string;
21
+ directoryName: string;
22
+ }
23
+
24
+ export interface MemoryRecord {
25
+ id: number;
26
+ memoryId: string;
27
+ scope: MemoryScope;
28
+ projectId: string | null;
29
+ kind: MemoryKind;
30
+ title: string;
31
+ content: string;
32
+ tags: string[];
33
+ importance: number;
34
+ confidence: number;
35
+ status: MemoryStatus;
36
+ sourceKind: MemorySourceKind;
37
+ sourceRef: string;
38
+ sourceEntryIds: string[];
39
+ fingerprint: string;
40
+ createdAt: number;
41
+ updatedAt: number;
42
+ lastAccessedAt: number | null;
43
+ accessCount: number;
44
+ }
45
+
46
+ export interface MemoryCandidate {
47
+ operation: "add" | "update" | "supersede" | "ignore";
48
+ targetId?: string;
49
+ scope: MemoryScope;
50
+ kind: MemoryKind;
51
+ title: string;
52
+ content: string;
53
+ tags: string[];
54
+ importance: number;
55
+ confidence: number;
56
+ evidenceEntryIds: string[];
57
+ }
58
+
59
+ export interface SearchResult extends MemoryRecord {
60
+ score: number;
61
+ }
62
+
63
+ export interface CapturedSession {
64
+ sessionId: string;
65
+ sessionFile: string;
66
+ project: ProjectIdentity;
67
+ leafId: string;
68
+ entryIds: string[];
69
+ transcript: string;
70
+ queryText: string;
71
+ capturedAt: number;
72
+ }
73
+
74
+ export interface CaptureJob {
75
+ id: number;
76
+ jobKey: string;
77
+ sessionId: string;
78
+ projectId: string;
79
+ status: "pending" | "running" | "complete" | "failed";
80
+ attempts: number;
81
+ payload: CapturedSession;
82
+ }
@@ -0,0 +1,18 @@
1
+ # Shared Extension Helpers
2
+
3
+ Internal runtime utilities used by more than one extension. This directory is not a Pi extension and has no `index.ts` entrypoint in the package manifest.
4
+
5
+ ## Modules
6
+
7
+ - `activity-status.ts` formats compact running/completed/failed status counts.
8
+ - `child-session.ts` owns trust-aware child resources and bounded session shutdown.
9
+ - `context-utilization.ts` formats model-context usage and capacity.
10
+ - `dashboard-state.ts` keeps list selection stable as live rows change.
11
+ - `tool-call-timeout.ts` applies cancellation-aware execution limits to registered tools.
12
+ - `tui-dashboard.ts` provides bounded, sanitized terminal-dashboard rendering helpers.
13
+
14
+ Keep extension-specific behavior in its owning `extensions/<name>` directory. Move code here only when multiple extensions genuinely share the same runtime contract.
15
+
16
+ ## Validation
17
+
18
+ From the repository root, run `npm run validate`.
@@ -0,0 +1,31 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type Theme = ExtensionContext["ui"]["theme"];
4
+
5
+ interface ActivityCounts {
6
+ running: number;
7
+ done: number;
8
+ failed: number;
9
+ }
10
+
11
+ const SQUARE = "■";
12
+
13
+ export function formatActivityStatus(
14
+ theme: Theme,
15
+ label: "subagents" | "workflows",
16
+ counts: ActivityCounts,
17
+ ) {
18
+ const parts: string[] = [];
19
+ if (counts.running > 0) {
20
+ parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
21
+ }
22
+ if (counts.done > 0) {
23
+ parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
24
+ }
25
+ if (counts.failed > 0) {
26
+ parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
27
+ }
28
+ parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view"));
29
+
30
+ return `${theme.fg("muted", `${label}:`)} ${parts.join(theme.fg("dim", " · "))}`;
31
+ }
@@ -0,0 +1,150 @@
1
+ import * as path from "node:path";
2
+ import {
3
+ DefaultResourceLoader,
4
+ getAgentDir,
5
+ ProjectTrustStore,
6
+ SettingsManager,
7
+ type AgentSession,
8
+ type SessionShutdownEvent,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
12
+
13
+ /** Tools that headless children must not receive. Everything else stays enabled. */
14
+ export const CHILD_EXCLUDED_TOOL_NAMES = [
15
+ "subagent_spawn",
16
+ "subagent_wait",
17
+ "subagent_cancel",
18
+ "subagent_check",
19
+ "subagent_list",
20
+ "workflow",
21
+ "create_orchestration",
22
+ "get_orchestration",
23
+ "ask_user",
24
+ ] as const;
25
+
26
+ /** Fresh SDK options avoid turning the denylist into an accidental allowlist. */
27
+ export function childToolPolicy() {
28
+ return { excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES] };
29
+ }
30
+
31
+ export interface ChildResourceOptions {
32
+ cwd: string;
33
+ projectTrusted: boolean;
34
+ appendSystemPrompt?: string[];
35
+ agentDir?: string;
36
+ }
37
+
38
+ /** Load normal global/package resources and trust-gated project resources. */
39
+ export async function createChildResources(options: ChildResourceOptions) {
40
+ const agentDir = options.agentDir ?? getAgentDir();
41
+ const settingsManager = SettingsManager.create(options.cwd, agentDir, {
42
+ projectTrusted: options.projectTrusted,
43
+ });
44
+ const loader = new DefaultResourceLoader({
45
+ cwd: options.cwd,
46
+ agentDir,
47
+ settingsManager,
48
+ ...(options.appendSystemPrompt
49
+ ? { appendSystemPrompt: options.appendSystemPrompt }
50
+ : {}),
51
+ });
52
+ await loader.reload();
53
+ return { loader, settingsManager };
54
+ }
55
+
56
+ /**
57
+ * Same-directory children inherit the live parent decision. An alternate cwd
58
+ * is trusted only when Pi's persisted trust store explicitly trusts it (or a
59
+ * containing directory); unreadable/invalid trust data fails closed.
60
+ */
61
+ export function resolveStandaloneChildProjectTrust(options: {
62
+ parentCwd: string;
63
+ childCwd: string;
64
+ parentTrusted: boolean;
65
+ agentDir?: string;
66
+ }) {
67
+ if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
68
+ return options.parentTrusted;
69
+ }
70
+ try {
71
+ const trustStore = new ProjectTrustStore(options.agentDir ?? getAgentDir());
72
+ return trustStore.get(options.childCwd) === true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /** Start child extension session hooks/resources in headless print mode. */
79
+ export async function bindChildSessionExtensions(
80
+ session: Pick<AgentSession, "bindExtensions">,
81
+ ) {
82
+ await session.bindExtensions({ mode: "print" });
83
+ }
84
+
85
+ interface ChildExtensionRunner {
86
+ hasHandlers(eventType: string): boolean;
87
+ emit(event: SessionShutdownEvent): Promise<unknown>;
88
+ }
89
+
90
+ export interface DisposableChildSession {
91
+ readonly extensionRunner: ChildExtensionRunner;
92
+ dispose(): void;
93
+ }
94
+
95
+ const childShutdowns = new WeakMap<object, Promise<void>>();
96
+
97
+ function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
98
+ let timer: ReturnType<typeof setTimeout> | undefined;
99
+ const timeout = new Promise<void>((resolve) => {
100
+ timer = setTimeout(resolve, timeoutMs);
101
+ });
102
+ return Promise.race([
103
+ operation.then(
104
+ () => undefined,
105
+ () => undefined,
106
+ ),
107
+ timeout,
108
+ ])
109
+ .catch(() => {})
110
+ .finally(() => {
111
+ if (timer) clearTimeout(timer);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Emit child session_shutdown once, then dispose once. Hook failures and a
117
+ * bounded hook deadline never prevent disposal.
118
+ */
119
+ export function shutdownAndDisposeChildSession(
120
+ session: DisposableChildSession,
121
+ options: { timeoutMs?: number } = {},
122
+ ) {
123
+ const existing = childShutdowns.get(session);
124
+ if (existing) return existing;
125
+
126
+ const shutdown = (async () => {
127
+ try {
128
+ if (session.extensionRunner.hasHandlers("session_shutdown")) {
129
+ await waitBounded(
130
+ session.extensionRunner.emit({
131
+ type: "session_shutdown",
132
+ reason: "quit",
133
+ }),
134
+ options.timeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS,
135
+ );
136
+ }
137
+ } catch {
138
+ // Extension runner inspection/emission is best-effort during teardown.
139
+ } finally {
140
+ try {
141
+ session.dispose();
142
+ } catch {
143
+ // Disposal is terminal and must remain idempotent for callers.
144
+ }
145
+ }
146
+ })();
147
+
148
+ childShutdowns.set(session, shutdown);
149
+ return shutdown;
150
+ }
@@ -0,0 +1,47 @@
1
+ /** Compact, defensive context-window utilization formatting for child agents. */
2
+
3
+ export interface ContextUtilization {
4
+ /** Current conversation context occupancy; null while it is unknown after compaction. */
5
+ tokens?: number | null;
6
+ /** Capacity of the model currently serving the conversation. */
7
+ contextWindow?: number | null;
8
+ }
9
+
10
+ function usableTokens(value: number | null | undefined) {
11
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
12
+ ? value
13
+ : undefined;
14
+ }
15
+
16
+ function usableCapacity(value: number | null | undefined) {
17
+ return typeof value === "number" && Number.isFinite(value) && value > 0
18
+ ? value
19
+ : undefined;
20
+ }
21
+
22
+ export function contextPercent(usage: ContextUtilization) {
23
+ const tokens = usableTokens(usage.tokens);
24
+ const capacity = usableCapacity(usage.contextWindow);
25
+ if (tokens === undefined || capacity === undefined) return undefined;
26
+ return Math.round(Math.min(100, Math.max(0, (tokens / capacity) * 100)));
27
+ }
28
+
29
+ export function formatCompactTokens(count: number) {
30
+ if (count < 1000) return Math.round(count).toString();
31
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
32
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
33
+ return `${(count / 1000000).toFixed(1)}M`;
34
+ }
35
+
36
+ /**
37
+ * Render `%/capacity`. If occupancy is temporarily unknown (notably directly
38
+ * after compaction), retain the useful model capacity as `?%/capacity`. If no
39
+ * valid capacity is available, omit the statistic because no percentage can
40
+ * be computed.
41
+ */
42
+ export function formatContextUtilization(usage: ContextUtilization) {
43
+ const capacity = usableCapacity(usage.contextWindow);
44
+ if (capacity === undefined) return "";
45
+ const percent = contextPercent(usage);
46
+ return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
47
+ }
@@ -0,0 +1,99 @@
1
+ export const MODEL_INFO_CHANNEL = "dashboard:model-info";
2
+ export const GIT_INFO_CHANNEL = "dashboard:git-info";
3
+ export const REFRESH_CHANNEL = "dashboard:refresh";
4
+
5
+ export interface ModelInfoState {
6
+ provider: string;
7
+ modelId: string;
8
+ modelName: string;
9
+ thinking: string;
10
+ contextTokens: number | null;
11
+ contextWindow: number;
12
+ contextPercent: number | null;
13
+ cost: number;
14
+ tokensPerSecond: number | null;
15
+ generating: boolean;
16
+ }
17
+
18
+ export interface PullRequestInfo {
19
+ number: number;
20
+ url: string;
21
+ isDraft: boolean;
22
+ }
23
+
24
+ export interface GitInfoState {
25
+ isRepository: boolean;
26
+ branch: string | null;
27
+ changedFiles: number;
28
+ pullRequest: PullRequestInfo | null;
29
+ }
30
+
31
+ export function emptyModelInfoState(): ModelInfoState {
32
+ return {
33
+ provider: "",
34
+ modelId: "no-model",
35
+ modelName: "No model",
36
+ thinking: "off",
37
+ contextTokens: null,
38
+ contextWindow: 0,
39
+ contextPercent: null,
40
+ cost: 0,
41
+ tokensPerSecond: null,
42
+ generating: false,
43
+ };
44
+ }
45
+
46
+ export function emptyGitInfoState(): GitInfoState {
47
+ return {
48
+ isRepository: false,
49
+ branch: null,
50
+ changedFiles: 0,
51
+ pullRequest: null,
52
+ };
53
+ }
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null;
57
+ }
58
+
59
+ function isNullableNumber(value: unknown) {
60
+ return value === null || typeof value === "number";
61
+ }
62
+
63
+ export function isModelInfoState(value: unknown): value is ModelInfoState {
64
+ if (!isRecord(value)) return false;
65
+
66
+ return (
67
+ typeof value.provider === "string" &&
68
+ typeof value.modelId === "string" &&
69
+ typeof value.modelName === "string" &&
70
+ typeof value.thinking === "string" &&
71
+ isNullableNumber(value.contextTokens) &&
72
+ typeof value.contextWindow === "number" &&
73
+ isNullableNumber(value.contextPercent) &&
74
+ typeof value.cost === "number" &&
75
+ isNullableNumber(value.tokensPerSecond) &&
76
+ typeof value.generating === "boolean"
77
+ );
78
+ }
79
+
80
+ function isPullRequestInfo(value: unknown): value is PullRequestInfo {
81
+ if (!isRecord(value)) return false;
82
+
83
+ return (
84
+ typeof value.number === "number" &&
85
+ typeof value.url === "string" &&
86
+ typeof value.isDraft === "boolean"
87
+ );
88
+ }
89
+
90
+ export function isGitInfoState(value: unknown): value is GitInfoState {
91
+ if (!isRecord(value)) return false;
92
+
93
+ return (
94
+ typeof value.isRepository === "boolean" &&
95
+ (value.branch === null || typeof value.branch === "string") &&
96
+ typeof value.changedFiles === "number" &&
97
+ (value.pullRequest === null || isPullRequestInfo(value.pullRequest))
98
+ );
99
+ }
@@ -0,0 +1,104 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const CHILD_TOOL_CALL_TIMEOUT_MS = 3 * 60 * 1_000;
4
+
5
+ interface ToolRegistry {
6
+ getAllTools(): Array<{ name: string }>;
7
+ getToolDefinition(name: string): ToolDefinition | undefined;
8
+ }
9
+
10
+ function formatTimeout(timeoutMs: number) {
11
+ if (timeoutMs % 60_000 === 0) {
12
+ const minutes = timeoutMs / 60_000;
13
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
14
+ }
15
+ if (timeoutMs % 1_000 === 0) {
16
+ const seconds = timeoutMs / 1_000;
17
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
18
+ }
19
+ return `${timeoutMs} ms`;
20
+ }
21
+
22
+ export class ToolCallTimeoutError extends Error {
23
+ constructor(toolName: string, timeoutMs: number) {
24
+ super(
25
+ `Tool call "${toolName}" timed out after ${formatTimeout(timeoutMs)}.`,
26
+ );
27
+ this.name = "ToolCallTimeoutError";
28
+ }
29
+ }
30
+
31
+ export async function runWithToolCallTimeout<T>(
32
+ toolName: string,
33
+ timeoutMs: number,
34
+ signal: AbortSignal | undefined,
35
+ execute: (signal: AbortSignal) => Promise<T>,
36
+ ) {
37
+ const timeoutController = new AbortController();
38
+ const executionSignal = signal
39
+ ? AbortSignal.any([signal, timeoutController.signal])
40
+ : timeoutController.signal;
41
+ const timeoutError = new ToolCallTimeoutError(toolName, timeoutMs);
42
+ let timer: ReturnType<typeof setTimeout> | undefined;
43
+ const timeout = new Promise<never>((_resolve, reject) => {
44
+ timer = setTimeout(() => {
45
+ reject(timeoutError);
46
+ timeoutController.abort(timeoutError);
47
+ }, timeoutMs);
48
+ });
49
+
50
+ let removeAbortListener: (() => void) | undefined;
51
+ const aborted = new Promise<never>((_resolve, reject) => {
52
+ if (!signal) return;
53
+ const onAbort = () => {
54
+ reject(
55
+ signal.reason instanceof Error
56
+ ? signal.reason
57
+ : new Error(`Tool call "${toolName}" was aborted.`),
58
+ );
59
+ };
60
+ if (signal.aborted) {
61
+ onAbort();
62
+ return;
63
+ }
64
+ signal.addEventListener("abort", onAbort, { once: true });
65
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
66
+ });
67
+
68
+ try {
69
+ return await Promise.race([execute(executionSignal), timeout, aborted]);
70
+ } finally {
71
+ if (timer) clearTimeout(timer);
72
+ removeAbortListener?.();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Wrap every currently registered child tool with an independent execution
78
+ * timeout. Calling apply() again is safe and picks up tools registered later.
79
+ */
80
+ export function createToolCallTimeoutGuard(
81
+ timeoutMs = CHILD_TOOL_CALL_TIMEOUT_MS,
82
+ ) {
83
+ const wrapped = new WeakSet<ToolDefinition>();
84
+
85
+ const wrap = (definition: ToolDefinition) => {
86
+ if (wrapped.has(definition)) return;
87
+ wrapped.add(definition);
88
+
89
+ const execute = definition.execute;
90
+ definition.execute = async (toolCallId, params, signal, onUpdate, ctx) =>
91
+ runWithToolCallTimeout(definition.name, timeoutMs, signal, (signal) =>
92
+ execute.call(definition, toolCallId, params, signal, onUpdate, ctx),
93
+ );
94
+ };
95
+
96
+ return {
97
+ apply(session: ToolRegistry) {
98
+ for (const { name } of session.getAllTools()) {
99
+ const definition = session.getToolDefinition(name);
100
+ if (definition) wrap(definition);
101
+ }
102
+ },
103
+ };
104
+ }