@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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 (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Process-local observations used for status and one advisory provenance nudge.
3
+ *
4
+ * This is deliberately not authority. A server-confirmed claim is useful
5
+ * coordination context, but no filesystem, shell, commit, push, or completion
6
+ * path asks this store for permission. globalThis joins Pi's separate jiti
7
+ * module graphs without persisting state across processes.
8
+ */
9
+
10
+ export interface ObservedClaim {
11
+ sessionId: string;
12
+ taskId: string;
13
+ projectId?: string;
14
+ observedAt: number;
15
+ }
16
+
17
+ interface WorkStateStore {
18
+ claims: Map<string, ObservedClaim>;
19
+ nudgedProjects: Set<string>;
20
+ }
21
+
22
+ const WORK_STATE_KEY = Symbol.for("devspec.pi.work-state.v2");
23
+ const FULL_TASK_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ const TERMINAL_WORK_VERBS = new Set(["record_implementation", "release_work_item", "fail_work_item"]);
25
+ const MAX_OBSERVED_CLAIMS = 100;
26
+
27
+ function store(): WorkStateStore {
28
+ const root = globalThis as typeof globalThis & { [WORK_STATE_KEY]?: WorkStateStore };
29
+ const current = root[WORK_STATE_KEY];
30
+ if (!current || !(current.claims instanceof Map) || !(current.nudgedProjects instanceof Set)) {
31
+ root[WORK_STATE_KEY] = { claims: new Map(), nudgedProjects: new Set() };
32
+ }
33
+ return root[WORK_STATE_KEY]!;
34
+ }
35
+
36
+ function exactNonEmpty(value: unknown): string | undefined {
37
+ return typeof value === "string" && value.length > 0 && value === value.trim() ? value : undefined;
38
+ }
39
+
40
+ function record(value: unknown): Record<string, unknown> | null {
41
+ return value !== null && typeof value === "object" && !Array.isArray(value)
42
+ ? value as Record<string, unknown>
43
+ : null;
44
+ }
45
+
46
+ function resultDetails(value: unknown): Record<string, unknown> | null {
47
+ const root = record(value);
48
+ if (!root) return null;
49
+ return record(root.details) ?? root;
50
+ }
51
+
52
+ function responseProjectId(details: Record<string, unknown>): string | undefined {
53
+ const direct = exactNonEmpty(details.project_id);
54
+ if (direct) return direct;
55
+ const task = record(details.task);
56
+ const item = record(details.item);
57
+ return exactNonEmpty(task?.project_id) ??
58
+ exactNonEmpty(item?.project_id) ??
59
+ exactNonEmpty(record(item?.task)?.project_id);
60
+ }
61
+
62
+ function responseTaskId(details: Record<string, unknown>): string | undefined {
63
+ const direct = exactNonEmpty(details.task_id) ?? exactNonEmpty(details.id);
64
+ if (direct) return direct;
65
+ const task = record(details.task);
66
+ const item = record(details.item);
67
+ return exactNonEmpty(task?.id) ??
68
+ exactNonEmpty(item?.id) ??
69
+ exactNonEmpty(record(item?.task)?.id);
70
+ }
71
+
72
+ function matchingResponseItemId(args: Record<string, unknown>, details: Record<string, unknown>): string | undefined {
73
+ const requested = exactNonEmpty(args.task_id);
74
+ const confirmed = responseTaskId(details);
75
+ return requested && FULL_TASK_ID.test(requested) && confirmed === requested ? confirmed : undefined;
76
+ }
77
+
78
+ function hasNegativeMarker(details: Record<string, unknown>): boolean {
79
+ return details.error != null || details.isError === true || details.ok === false ||
80
+ details.success === false || details.claimed === false || details.blocked === true ||
81
+ details.is_blocked === true;
82
+ }
83
+
84
+ function terminalSucceeded(name: string, details: Record<string, unknown>): boolean {
85
+ if (hasNegativeMarker(details)) return false;
86
+ if (name === "record_implementation") {
87
+ return details.implementation_recorded === true || details.lifecycle === "implemented";
88
+ }
89
+ if (name === "release_work_item") {
90
+ return details.success === true && (
91
+ exactNonEmpty(details.released_at) !== undefined || typeof details.was_claimed === "boolean"
92
+ );
93
+ }
94
+ if (name === "fail_work_item") {
95
+ return details.failed === true || details.agent_activity === "failed";
96
+ }
97
+ return false;
98
+ }
99
+
100
+ function claimKey(sessionId: string, taskId: string): string {
101
+ return `${sessionId}\0${taskId}`;
102
+ }
103
+
104
+ export function listObservedClaims(sessionId?: string): ObservedClaim[] {
105
+ return [...store().claims.values()]
106
+ .filter((claim) => !sessionId || claim.sessionId === sessionId)
107
+ .map((claim) => ({ ...claim }));
108
+ }
109
+
110
+ export function hasObservedClaim(sessionId: string, projectId?: string): boolean {
111
+ return listObservedClaims(sessionId).some((claim) => !projectId || claim.projectId === projectId);
112
+ }
113
+
114
+ /**
115
+ * Consume only structured successful DevSpec results. The boolean means a claim
116
+ * was newly observed and is used for footer display; it grants no permission.
117
+ */
118
+ export function observeSuccessfulWorkResult(
119
+ name: string,
120
+ args: Record<string, unknown>,
121
+ result: unknown,
122
+ sessionId: string,
123
+ projectId?: string,
124
+ ): boolean {
125
+ const exactSessionId = exactNonEmpty(sessionId);
126
+ const details = resultDetails(result);
127
+ if (!exactSessionId || !details) return false;
128
+ const taskId = matchingResponseItemId(args, details);
129
+ if (!taskId) return false;
130
+ const resolvedProjectId = exactNonEmpty(projectId) ?? responseProjectId(details);
131
+
132
+ if (name === "claim_work_item") {
133
+ if (details.claim_success !== true || hasNegativeMarker(details)) return false;
134
+ const current = store();
135
+ current.claims.set(claimKey(exactSessionId, taskId), {
136
+ sessionId: exactSessionId,
137
+ taskId,
138
+ ...(resolvedProjectId ? { projectId: resolvedProjectId } : {}),
139
+ observedAt: Date.now(),
140
+ });
141
+ while (current.claims.size > MAX_OBSERVED_CLAIMS) {
142
+ const oldest = current.claims.keys().next().value;
143
+ if (typeof oldest !== "string") break;
144
+ current.claims.delete(oldest);
145
+ }
146
+ return true;
147
+ }
148
+
149
+ if (!TERMINAL_WORK_VERBS.has(name) || !terminalSucceeded(name, details)) return false;
150
+ store().claims.delete(claimKey(exactSessionId, taskId));
151
+ return false;
152
+ }
153
+
154
+ /** Returns true exactly once per Pi session and explicit project association. */
155
+ export function markProjectNudged(sessionId: string, projectId: string): boolean {
156
+ const exactSessionId = exactNonEmpty(sessionId);
157
+ const exactProjectId = exactNonEmpty(projectId);
158
+ if (!exactSessionId || !exactProjectId) return false;
159
+ const key = `${exactSessionId}\0${exactProjectId}`;
160
+ const current = store();
161
+ if (current.nudgedProjects.has(key)) return false;
162
+ // Session ids are unique and /reload stays in the same process. Retaining the
163
+ // spent key is what makes "at most one" absolute for that session/project.
164
+ current.nudgedProjects.add(key);
165
+ return true;
166
+ }
167
+
168
+ export function unmarkProjectNudged(sessionId: string, projectId: string): void {
169
+ const exactSessionId = exactNonEmpty(sessionId);
170
+ const exactProjectId = exactNonEmpty(projectId);
171
+ if (!exactSessionId || !exactProjectId) return;
172
+ store().nudgedProjects.delete(`${exactSessionId}\0${exactProjectId}`);
173
+ }
174
+
175
+ export function resetWorkStateForTests(): void {
176
+ const current = store();
177
+ current.claims.clear();
178
+ current.nudgedProjects.clear();
179
+ }
@@ -0,0 +1,106 @@
1
+ /** Prevent a second DevSpec connection without touching another host's MCP configuration. */
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ getAdapterGuardState, recordAdapterGuard, toolsToDeactivate, isAdapterSource,
7
+ SUPPRESSION_NOTICE, SUPPRESSION_AGENT_GUIDANCE, type DevspecServerHit,
8
+ } from "./core/adapter-overlap.ts";
9
+ import { configPaths, migrateLegacyOverlay, disableEffectiveNativeDuplicates, disableAdapterDuplicates, type AdapterConfigApi } from "./core/mcp-config-guard.ts";
10
+ import { mcpUrl } from "./core/config.ts";
11
+ import { debugRemote } from "./core/debug.ts";
12
+
13
+ export default function (pi: ExtensionAPI) {
14
+ let explained = false;
15
+ let changed = false;
16
+ let failed = false;
17
+ let hits: DevspecServerHit[] = [];
18
+ let nativeHits: DevspecServerHit[] = [];
19
+ let adapterHits: DevspecServerHit[] = [];
20
+ function duplicateTools() {
21
+ if (!nativeHits.length && !adapterHits.length) return [];
22
+ const tools = pi.getAllTools();
23
+ return [
24
+ ...toolsToDeactivate(tools.filter((tool) => tool.sourceInfo?.path === "builtin:mcp"), nativeHits.map((hit) => hit.name)),
25
+ ...toolsToDeactivate(tools.filter((tool) => isAdapterSource(tool.sourceInfo)), adapterHits.map((hit) => hit.name)),
26
+ ];
27
+ }
28
+
29
+ function deactivate(): void {
30
+ const names = new Set(duplicateTools());
31
+ // Config preflight prevents first registration. This also removes cached adapter
32
+ // declarations on the migration run; never overwrite another extension's tools.
33
+ const active = pi.getActiveTools();
34
+ const next = active.filter((name) => !names.has(name));
35
+ if (next.length !== active.length) pi.setActiveTools(next);
36
+ }
37
+
38
+ async function prepare(ctx: ExtensionContext): Promise<void> {
39
+ changed = false; failed = false; hits = []; nativeHits = []; adapterHits = [];
40
+ const trusted = ctx.isProjectTrusted();
41
+ try {
42
+ const paths = configPaths(getAgentDir(), ctx.cwd, trusted);
43
+ for (const path of paths) changed = migrateLegacyOverlay(path.native, path.adapter) || changed;
44
+ if (pi.getCommands().some((command) => command.name === "mcp" && command.sourceInfo?.path === "builtin:mcp")) {
45
+ const native = disableEffectiveNativeDuplicates(paths.map((path) => path.native), mcpUrl());
46
+ nativeHits = native.hits; hits.push(...native.hits); changed = native.changed || changed;
47
+ }
48
+ // Only consult shared/Claude configurations when the adapter is actually loaded.
49
+ // Resolve its PUBLIC config export from the loaded extension's own location,
50
+ // not our dependency graph and not a guessed global npm installation.
51
+ const adapter = pi.getCommands().find((command) => command.name === "mcp-adapter" || (command.name === "mcp" && isAdapterSource(command.sourceInfo)));
52
+ if (adapter && trusted) {
53
+ const source = adapter.sourceInfo?.path;
54
+ if (!source) throw new Error("Adapter source unavailable");
55
+ const entry = createRequire(source).resolve("pi-mcp-adapter/config");
56
+ const api = await import(pathToFileURL(entry).href) as AdapterConfigApi;
57
+ const flag = pi.getFlag("mcp-config");
58
+ const result = disableAdapterDuplicates(api, ctx.cwd, mcpUrl(), typeof flag === "string" ? flag : undefined);
59
+ adapterHits = result.hits; hits.push(...result.hits); changed = result.changed || changed;
60
+ }
61
+ } catch (error) {
62
+ failed = true;
63
+ // Config/error text can contain credentials. Never log it.
64
+ debugRemote("adapter guard failed", { errorPresent: Boolean(error) });
65
+ }
66
+ recordAdapterGuard({ suppressed: hits, persisted: !failed, overlayPath: null });
67
+ }
68
+
69
+ // Pi loads user extensions before its built-in MCP extension. Prepare only
70
+ // after project trust; never inspect a project's configuration during factory load.
71
+ pi.on("session_start", async (_event, ctx) => {
72
+ explained = false;
73
+ listenForAdapterCalls();
74
+ await prepare(ctx);
75
+ deactivate();
76
+ if (failed) ctx.ui.notify("DevSpec could not check duplicate MCP settings. Review Pi’s /mcp settings and any conflicting or malformed MCP files. If you use pi-mcp-adapter, update it to 4.0 or later, then reload. No shared MCP configuration was changed.", "warning");
77
+ else if (changed && ctx.hasUI && ctx.mode === "tui") {
78
+ ctx.ui.notify(hits.length ? SUPPRESSION_NOTICE : "Updated legacy Pi MCP settings. Shared MCP configuration and other agents are unchanged.", "info");
79
+ }
80
+ changed = false;
81
+ });
82
+ // An adapter loaded before us can already have cached registrations/connections.
83
+ // Block its resolved calls (including proxy/script routes), not just declarations.
84
+ let unsubscribe: (() => void) | undefined;
85
+ function listenForAdapterCalls() {
86
+ unsubscribe ??= pi.events.on("pi-mcp-adapter:tool-approval-request", (raw: unknown) => {
87
+ const request = raw as { serverName?: string; claim?: (handler: () => string) => boolean };
88
+ if (adapterHits.some((hit) => hit.name === request?.serverName) && typeof request.claim === "function") request.claim(() => "deny");
89
+ });
90
+ }
91
+ pi.on("session_shutdown", async () => { unsubscribe?.(); unsubscribe = undefined; });
92
+ pi.on("tool_call", async (event) => {
93
+ const names = duplicateTools().filter((name) => name === event.toolName);
94
+ const resource = ["read_mcp_resource", "list_mcp_resources", "list_mcp_resource_templates"].includes(event.toolName);
95
+ if (names.length || (resource && nativeHits.some((hit) => hit.name === (event.input as Record<string, unknown>).server))) {
96
+ return { block: true, reason: "Use the native DevSpec tools instead of the duplicate MCP connection." };
97
+ }
98
+ });
99
+ pi.on("tool_result", async () => { deactivate(); });
100
+ pi.on("before_agent_start", async () => {
101
+ deactivate();
102
+ if (explained || getAdapterGuardState().suppressed.length === 0) return;
103
+ explained = true;
104
+ return { message: { customType: "devspec-mcp-guard", content: SUPPRESSION_AGENT_GUIDANCE, display: false } };
105
+ });
106
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * DevSpec Pi — authentication extension.
3
+ *
4
+ * Commands:
5
+ * /devspec.login [token] — verify + store a token (paste from DevSpec's
6
+ * Agent setup page), or use DEVSPEC_MCP_TOKEN
7
+ * /devspec.logout — remove the stored token
8
+ * /devspec.status — show connection state
9
+ *
10
+ * On session start: resolves the token, shows connection state in the footer,
11
+ * and verifies once per process (never on a timer — rate budget matters).
12
+ *
13
+ * Headless (print/json modes): no prompts, no status line; login explains the
14
+ * env-var path instead. See VISION.md principle 2 (headless-first).
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { getAdapterGuardState, SUPPRESSION_NOTICE } from "./core/adapter-overlap.ts";
19
+ import { clearAuth, resolveEndpoint, resolveToken, saveAuth } from "./core/auth-store.ts";
20
+ import { agentsSettingsUrl, mcpUrl, TOKEN_ENV_VAR } from "./core/config.ts";
21
+ import { DevspecAuthError, resetClientCache, verifyToken } from "./core/client.ts";
22
+ import { unwrapMcpResult } from "./core/result.ts";
23
+
24
+ const STATUS_KEY = "devspec";
25
+
26
+ /** Process-local verification state — verify at most once per process. */
27
+ let verifiedPrincipal: string | null = null;
28
+ let verifyAttempted = false;
29
+ let verifiedConnection = false;
30
+
31
+ function setStatus(ctx: ExtensionContext, text: string | undefined): void {
32
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, text);
33
+ }
34
+
35
+ function readablePrincipal(value: unknown): string | null {
36
+ if (typeof value !== "string") return null;
37
+ const name = value.trim();
38
+ // Identity is display data, not response syntax or terminal control output.
39
+ // Do not impose a length cap or silently crop a person's name (6673868e).
40
+ if (!name || /^[{\[]/.test(name) || /[\u0000-\u001f\u007f-\u009f]/.test(name)) return null;
41
+ return name;
42
+ }
43
+
44
+ /** Only structured identity fields, never an arbitrary first response line. */
45
+ export function principalFrom(result: unknown): string | null {
46
+ const { details, isError } = unwrapMcpResult(result);
47
+ if (isError) return null;
48
+ if (details && typeof details === "object") {
49
+ const d = details as Record<string, unknown>;
50
+ // connected_as is verify_agent_connection's current wire field. Keep the
51
+ // explicit older identity shapes, but never fall back to its message text.
52
+ for (const key of ["connected_as", "name", "user_name", "display_name", "email", "principal"]) {
53
+ const name = readablePrincipal(d[key]);
54
+ if (name) return name;
55
+ }
56
+ if (details && typeof (d.user as unknown) === "object" && d.user !== null) {
57
+ const u = d.user as Record<string, unknown>;
58
+ return readablePrincipal(u.name) ?? readablePrincipal(u.email);
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ async function doLogin(token: string, ctx: ExtensionContext): Promise<boolean> {
65
+ try {
66
+ const result = await verifyToken(token);
67
+ const { isError, text } = unwrapMcpResult(result);
68
+ if (isError) {
69
+ ctx.ui.notify(`DevSpec login failed: ${text}`, "error");
70
+ return false;
71
+ }
72
+ const principal = principalFrom(result);
73
+ verifiedPrincipal = principal;
74
+ verifyAttempted = true;
75
+ verifiedConnection = true;
76
+ await saveAuth({ token, url: mcpUrl(), principal: principal ?? undefined, verifiedAt: new Date().toISOString() });
77
+ setStatus(ctx, undefined); // Successful identity is available in /devspec.status, not the footer.
78
+ const extra = getAdapterGuardState().suppressed.length > 0 ? ` ${SUPPRESSION_NOTICE}` : "";
79
+ ctx.ui.notify(`Connected to DevSpec${principal ? ` as ${principal}` : ""}.${extra}`, "info");
80
+ return true;
81
+ } catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ ctx.ui.notify(`DevSpec login failed: ${message}`, "error");
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export default function (pi: ExtensionAPI) {
89
+ pi.registerCommand("devspec.login", {
90
+ description: `Connect to DevSpec. Paste an access token from ${agentsSettingsUrl()} (or set ${TOKEN_ENV_VAR}).`,
91
+ handler: async (args, ctx) => {
92
+ const envToken = process.env[TOKEN_ENV_VAR];
93
+ let token = args.trim();
94
+
95
+ if (!token && envToken) {
96
+ token = envToken.trim();
97
+ ctx.ui.notify(`Using token from ${TOKEN_ENV_VAR}.`, "info");
98
+ }
99
+
100
+ if (!token) {
101
+ if (!ctx.hasUI) {
102
+ // Headless: no prompt possible. Tell the operator the env path.
103
+ ctx.ui.notify(
104
+ `DevSpec login: set ${TOKEN_ENV_VAR} (mint one at ${agentsSettingsUrl()}) and re-run.`,
105
+ "warning",
106
+ );
107
+ return;
108
+ }
109
+ const entered = await ctx.ui.input(
110
+ "Connect to DevSpec",
111
+ `Paste an access token from ${agentsSettingsUrl()}`,
112
+ );
113
+ token = (entered ?? "").trim();
114
+ if (!token) {
115
+ ctx.ui.notify("DevSpec login cancelled.", "info");
116
+ return;
117
+ }
118
+ }
119
+
120
+ await doLogin(token, ctx);
121
+ },
122
+ });
123
+
124
+ pi.registerCommand("devspec.logout", {
125
+ description: "Remove the stored DevSpec token from this machine.",
126
+ handler: async (_args, ctx) => {
127
+ await clearAuth();
128
+ resetClientCache();
129
+ verifiedPrincipal = null;
130
+ verifyAttempted = false;
131
+ verifiedConnection = false;
132
+ setStatus(ctx, undefined);
133
+ ctx.ui.notify(
134
+ `DevSpec token removed.${process.env[TOKEN_ENV_VAR] ? ` Note: ${TOKEN_ENV_VAR} is still set in the environment.` : ""}`,
135
+ "info",
136
+ );
137
+ },
138
+ });
139
+
140
+ pi.registerCommand("devspec.status", {
141
+ description: "Show DevSpec connection state (token source, principal, endpoint).",
142
+ handler: async (_args, ctx) => {
143
+ const resolved = await resolveToken();
144
+ if (!resolved) {
145
+ ctx.ui.notify(
146
+ `DevSpec: not connected. Run /devspec.login or set ${TOKEN_ENV_VAR}. Tokens: ${agentsSettingsUrl()}`,
147
+ "warning",
148
+ );
149
+ return;
150
+ }
151
+ const who = verifiedPrincipal ?? (verifiedConnection ? "connected" : "not verified this session");
152
+ const endpoint = await resolveEndpoint();
153
+ ctx.ui.notify(
154
+ `DevSpec: token from ${resolved.source === "env" ? TOKEN_ENV_VAR : "login store"} · ${who} · ${endpoint}`,
155
+ "info",
156
+ );
157
+ },
158
+ });
159
+
160
+ pi.on("session_start", async (_event, ctx) => {
161
+ const resolved = await resolveToken();
162
+ if (!resolved) {
163
+ setStatus(ctx, undefined);
164
+ if (ctx.hasUI && ctx.mode === "tui") {
165
+ ctx.ui.notify(`DevSpec: not connected — /devspec.login (tokens: ${agentsSettingsUrl()})`, "warning");
166
+ }
167
+ return;
168
+ }
169
+
170
+ setStatus(ctx, "DevSpec: …");
171
+
172
+ // Verify once per process, in the background — never block startup,
173
+ // never on a timer (the per-token rate budget is shared across plugins).
174
+ if (!verifyAttempted) {
175
+ verifyAttempted = true;
176
+ void verifyToken(resolved.token)
177
+ .then((result) => {
178
+ const { isError } = unwrapMcpResult(result);
179
+ if (isError) throw new DevspecAuthError("verify returned error");
180
+ verifiedPrincipal = principalFrom(result);
181
+ verifiedConnection = true;
182
+ setStatus(ctx, undefined);
183
+ })
184
+ .catch(() => {
185
+ setStatus(ctx, "DevSpec: offline?");
186
+ });
187
+ } else {
188
+ setStatus(ctx, undefined);
189
+ }
190
+ });
191
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * DevSpec Pi — brand header.
3
+ *
4
+ * Shows the DevSpec duck when pi launches (TUI only; headless modes never
5
+ * render a header). Toggle with /devspec.logo — persisted in
6
+ * <pi-agent-dir>/devspec/brand.json.
7
+ *
8
+ * The duck itself lives in core/duck.ts. This module only lays it out: the
9
+ * wordmark sits beside the mark when there is room for a lockup, drops below it
10
+ * when there isn't, and the mark is dropped entirely on a terminal too narrow
11
+ * to hold it — art that wraps looks worse than no art.
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { VERSION } from "@earendil-works/pi-coding-agent";
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { devspecDir } from "./core/config.ts";
19
+ import { HEIGHT, renderDuck, WIDTH } from "./core/duck.ts";
20
+
21
+ const TEAL = "\x1b[38;2;45;212;191m"; // brightened #0F766E brand teal for terminal
22
+ const DIM = "\x1b[38;2;130;130;130m";
23
+ const BOLD = "\x1b[1m";
24
+ const RESET = "\x1b[0m";
25
+
26
+ /** Columns between the mark and the wordmark in the side-by-side lockup. */
27
+ const GAP = " ";
28
+
29
+ /**
30
+ * Approved terminal adaptation of the public brand family (de447cb7).
31
+ * Keep it short: its length sets the width at which the tagline can sit
32
+ * beside the mark. Narrower terminals retain the existing name-only fallback.
33
+ */
34
+ export const TAGLINE = "Your team’s knowledge, where you build.";
35
+
36
+ /**
37
+ * Pick the first rendering whose plain text fits `available` columns, or "" if
38
+ * none does. Each candidate is [rendered-with-colour, plain-for-measuring].
39
+ */
40
+ function fit(candidates: readonly (readonly [string, string])[], available: number): string {
41
+ for (const [rendered, plain] of candidates) {
42
+ if (plain.length <= available) return rendered;
43
+ }
44
+ return "";
45
+ }
46
+
47
+ /**
48
+ * Wordmark + subtitle, each trimmed to what fits in `available` columns. Detail
49
+ * is shed longest-first — the tagline goes before the name, the pi version
50
+ * before the help hint — and a line that cannot fit at all comes back empty for
51
+ * the caller to drop.
52
+ */
53
+ function textLines(available: number): [string, string] {
54
+ const wordmark = fit(
55
+ [
56
+ [`${TEAL}${BOLD}DevSpec${RESET}${DIM} · ${TAGLINE}${RESET}`, `DevSpec · ${TAGLINE}`],
57
+ [`${TEAL}${BOLD}DevSpec${RESET}`, "DevSpec"],
58
+ ],
59
+ available,
60
+ );
61
+ const subtitle = fit(
62
+ [
63
+ [`${DIM}on pi v${VERSION} · /devspec.help${RESET}`, `on pi v${VERSION} · /devspec.help`],
64
+ [`${DIM}/devspec.help${RESET}`, "/devspec.help"],
65
+ ],
66
+ available,
67
+ );
68
+ return [wordmark, subtitle];
69
+ }
70
+
71
+ /** Vertically centre the two text lines against the mark. */
72
+ const TEXT_ROW = Math.max(0, Math.floor((HEIGHT - 2) / 2));
73
+
74
+ export function renderHeader(width: number): string[] {
75
+ if (width < WIDTH) {
76
+ return [...textLines(width).filter(Boolean), ""];
77
+ }
78
+
79
+ const lines = renderDuck();
80
+ const beside = Math.max(0, width - WIDTH - GAP.length);
81
+ const [wordmark, subtitle] = textLines(beside);
82
+
83
+ // Lockup: wordmark beside the mark, but only if the name itself fits there.
84
+ if (wordmark) {
85
+ lines[TEXT_ROW] = `${lines[TEXT_ROW]}${GAP}${wordmark}`;
86
+ if (subtitle) lines[TEXT_ROW + 1] = `${lines[TEXT_ROW + 1]}${GAP}${subtitle}`;
87
+ return [...lines, ""];
88
+ }
89
+
90
+ return [...lines, ...textLines(width).filter(Boolean), ""];
91
+ }
92
+
93
+ function brandFilePath(): string {
94
+ return join(devspecDir(), "brand.json");
95
+ }
96
+
97
+ async function loadBrandPref(): Promise<boolean> {
98
+ try {
99
+ const raw = JSON.parse(await readFile(brandFilePath(), "utf8"));
100
+ return raw.showHeader !== false; // default: show
101
+ } catch {
102
+ return true;
103
+ }
104
+ }
105
+
106
+ async function saveBrandPref(showHeader: boolean): Promise<void> {
107
+ const { mkdir } = await import("node:fs/promises");
108
+ await mkdir(devspecDir(), { recursive: true });
109
+ await writeFile(brandFilePath(), JSON.stringify({ showHeader }) + "\n");
110
+ }
111
+
112
+ function applyHeader(ctx: ExtensionContext, show: boolean): void {
113
+ if (ctx.mode !== "tui") return;
114
+ if (!show) {
115
+ ctx.ui.setHeader(undefined);
116
+ return;
117
+ }
118
+ ctx.ui.setHeader((_tui, _theme) => ({
119
+ render(width: number): string[] {
120
+ return renderHeader(width);
121
+ },
122
+ invalidate() {},
123
+ }));
124
+ }
125
+
126
+ export default function (pi: ExtensionAPI) {
127
+ pi.on("session_start", async (_event, ctx) => {
128
+ applyHeader(ctx, await loadBrandPref());
129
+ });
130
+
131
+ pi.registerCommand("devspec.logo", {
132
+ description: "Toggle the DevSpec duck header on startup.",
133
+ handler: async (_args, ctx) => {
134
+ const next = !(await loadBrandPref());
135
+ await saveBrandPref(next);
136
+ applyHeader(ctx, next);
137
+ ctx.ui.notify(`DevSpec header ${next ? "on" : "off"}.`, "info");
138
+ },
139
+ });
140
+ }