@danypops/papyrus 0.15.2 → 0.17.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.
@@ -0,0 +1,55 @@
1
+ import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
3
+ import type { ContextSegmentItem } from "./context-budget.ts";
4
+
5
+ /**
6
+ * Splits Pi's base system prompt into real structural sub-segments instead of one opaque
7
+ * number, using BeforeAgentStartEvent's own systemPromptOptions field -- Pi's own doc comment
8
+ * on it: "Extensions can inspect this to understand what Pi loaded without re-discovering
9
+ * resources." No new hook, no new risk: before_agent_start is already wired.
10
+ *
11
+ * Deliberately measures each INPUT's raw content size (tool snippet text, skill metadata,
12
+ * context file content) rather than attempting to byte-for-byte reproduce Pi's internal
13
+ * wrapping/tag format -- buildSystemPrompt() and formatSkillsForPrompt() are Pi-internal
14
+ * functions, not part of the public extension API Papyrus can call, so reproducing their
15
+ * exact template text here would be a real, silent drift risk if Pi ever changes it. The
16
+ * remainder item absorbs whatever wrapping/template text this doesn't attribute, so the
17
+ * segment's total always still matches the real observed prompt length exactly -- honesty
18
+ * preserved even though individual sub-segment sizes are approximate, matching the same
19
+ * known-segments-plus-honest-remainder pattern used everywhere else in this breakdown.
20
+ */
21
+ export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCharacters: number): ContextSegmentItem[] {
22
+ const items: ContextSegmentItem[] = [];
23
+
24
+ const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
25
+ // Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
26
+ // fair estimate without importing Pi-internal formatting code.
27
+ const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
28
+ if (toolSnippetsCharacters > 0) {
29
+ items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
30
+ }
31
+
32
+ const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
33
+ const skillsCharacters = visibleSkills.reduce((sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20, 0);
34
+ if (skillsCharacters > 0) {
35
+ items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
36
+ }
37
+
38
+ const contextFiles = options.contextFiles ?? [];
39
+ const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
40
+ if (contextFilesCharacters > 0) {
41
+ items.push({ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`, estimatedTokens: toCeilTokens(contextFilesCharacters) });
42
+ }
43
+
44
+ const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
45
+ const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
46
+ if (remainderCharacters > 0 || items.length === 0) {
47
+ items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
48
+ }
49
+
50
+ return items;
51
+ }
52
+
53
+ function toCeilTokens(characters: number): number {
54
+ return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
55
+ }
@@ -295,6 +295,8 @@ export interface BuildContextBreakdownInput {
295
295
  skills: SkillCatalogFootprint;
296
296
  /** Pi's own base system prompt size, cached from the last observed before_agent_start turn. Null before any turn has run yet. */
297
297
  basePromptEstimatedTokens: number | null;
298
+ /** Structural sub-breakdown (tool snippets, Skills, context files, template remainder) from the same cached observation, built by buildBasePromptItems(). Empty when basePromptEstimatedTokens is null. */
299
+ basePromptItems?: ContextSegmentItem[];
298
300
  /** From buildMessageHistoryTree() against the live session's real tree (ctx.sessionManager.getTree()). */
299
301
  messageHistoryItems: ContextSegmentItem[];
300
302
  /** buildMessageHistoryTree()'s activeTokens -- only entries on the current active path count toward the segment total; an abandoned /tree branch still appears in messageHistoryItems but contributes zero here. */
@@ -416,6 +418,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
416
418
  label: input.basePromptEstimatedTokens === null ? "Base system prompt (not observed yet)" : "Base system prompt (Pi + host instructions)",
417
419
  estimatedTokens: input.basePromptEstimatedTokens ?? 0,
418
420
  ...(input.basePromptEstimatedTokens === null ? { unknown: true } : {}),
421
+ ...(input.basePromptItems && input.basePromptItems.length > 0 ? { items: input.basePromptItems } : {}),
419
422
  };
420
423
  const messageHistory: ContextSegment = {
421
424
  key: "messageHistory",
@@ -8,6 +8,7 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
8
8
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
11
+ import { sessionSecretField } from "./session-identity.ts";
11
12
  import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
12
13
  import { callService } from "./service-client.ts";
13
14
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
@@ -77,7 +78,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
77
78
  const action = params.action;
78
79
  // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
79
80
  // without depending on the model to know or supply its own session identity.
80
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: params.session_id ?? ctx.sessionManager.getSessionId() };
81
+ // session_secret is looked up by the resolved session_id itself (not blindly the
82
+ // current session's), so a model that explicitly overrides session_id to a DIFFERENT
83
+ // session never gets this session's secret smuggled in on its behalf -- the cache only
84
+ // ever holds this extension's own registered session anyway (see session-identity.ts).
85
+ const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
86
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
81
87
  if (action === "create") {
82
88
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
83
89
  return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
@@ -27,9 +27,11 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
27
27
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
28
28
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
29
29
  import { buildContextInjection } from "./context-injection-telemetry.ts";
30
- import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
30
+ import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
31
+ import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
31
32
  import { showContextView } from "./context-view.ts";
32
33
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
34
+ import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
33
35
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
34
36
  import {
35
37
  createArtifactDetails,
@@ -164,8 +166,12 @@ export default async function (pi: ExtensionAPI) {
164
166
  // Cached from the most recent before_agent_start observation: Pi's own base system prompt
165
167
  // is only ever visible transiently inside that hook's event.systemPrompt, so /context
166
168
  // reuses the size buildContextInjection already computes every turn rather than going
167
- // without it entirely.
169
+ // without it entirely. basePromptItems is the structural sub-breakdown built from the same
170
+ // event's systemPromptOptions field ("Extensions can inspect this to understand what Pi
171
+ // loaded without re-discovering resources", per Pi's own doc comment) -- no new hook, no new
172
+ // risk, just reading a field before_agent_start already hands over.
168
173
  let lastObservedBasePromptTokens: number | null = null;
174
+ let lastObservedBasePromptItems: ContextSegmentItem[] = [];
169
175
  const taskContinuation = new ActiveTaskContinuation({
170
176
  maxTurns: TASK_DRIVER_MAX_TURNS,
171
177
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
@@ -192,6 +198,7 @@ export default async function (pi: ExtensionAPI) {
192
198
  source: "task-continuation",
193
199
  reason: automaticPauseReason(decision.reason),
194
200
  session_id: sessionId,
201
+ ...sessionSecretField(sessionId),
195
202
  });
196
203
  emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
197
204
  if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
@@ -480,6 +487,7 @@ export default async function (pi: ExtensionAPI) {
480
487
  taskItems: buildTaskItemTree(taskGraph),
481
488
  skills,
482
489
  basePromptEstimatedTokens: lastObservedBasePromptTokens,
490
+ basePromptItems: lastObservedBasePromptItems,
483
491
  messageHistoryItems: messageHistory.items,
484
492
  messageHistoryActiveTokens: messageHistory.activeTokens,
485
493
  });
@@ -493,6 +501,19 @@ export default async function (pi: ExtensionAPI) {
493
501
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
494
502
 
495
503
  pi.on("session_start", async (_event, ctx) => {
504
+ // Registers this session's identity with the daemon as early as possible -- before any
505
+ // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
506
+ // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
507
+ // unavailable during startup, and every other Focus-mutating call already tolerates an
508
+ // unregistered/never-armored session_id (opt-in armor), so a missed registration here is
509
+ // not worth surfacing to the user.
510
+ try {
511
+ const sessionId = ctx.sessionManager.getSessionId();
512
+ const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
513
+ cacheSessionSecret(sessionId, secret);
514
+ } catch {
515
+ // intentionally silent -- see comment above
516
+ }
496
517
  if (!ctx.hasUI) return;
497
518
  overlay ??= new TaskOverlay();
498
519
  overlay.setUI(ctx.ui);
@@ -504,7 +525,17 @@ export default async function (pi: ExtensionAPI) {
504
525
  pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
505
526
  pi.on("session_compact", async () => { await overlay?.refresh(); });
506
527
  pi.on("session_tree", async () => { await overlay?.refresh(); });
507
- pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
528
+ pi.on("session_shutdown", async (_event, ctx) => {
529
+ overlay?.dispose();
530
+ overlay = undefined;
531
+ try {
532
+ const sessionId = ctx.sessionManager.getSessionId();
533
+ await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
534
+ forgetSessionSecret(sessionId);
535
+ } catch {
536
+ // intentionally silent -- see session_start's comment above
537
+ }
538
+ });
508
539
 
509
540
  // Update widget after any papyrus tool call
510
541
  pi.on("tool_execution_end", async (event) => {
@@ -524,7 +555,7 @@ export default async function (pi: ExtensionAPI) {
524
555
  const sessionId = ctx.sessionManager.getSessionId();
525
556
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
526
557
  if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
527
- await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
558
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId, ...sessionSecretField(sessionId) });
528
559
  emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
529
560
  }
530
561
  } catch {
@@ -559,6 +590,7 @@ export default async function (pi: ExtensionAPI) {
559
590
  });
560
591
  previousContextInjectionFingerprint = injection.observation.fingerprint;
561
592
  lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
593
+ lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, injection.observation.before.characters);
562
594
  pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
563
595
  if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
564
596
  } catch {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Client-side cache for the extension's own session_secret, registered with the daemon at
3
+ * session_start and released at session_shutdown (see index.ts). Keyed by sessionId (not a
4
+ * single "current" variable) defensively -- multiple call sites reference an explicit
5
+ * sessionId already, and a Map costs nothing extra for real correctness. See
6
+ * src/domain/session-identity.ts (daemon side) for the full design rationale.
7
+ */
8
+ const secretsBySessionId = new Map<string, string>();
9
+
10
+ export function cacheSessionSecret(sessionId: string, secret: string): void {
11
+ secretsBySessionId.set(sessionId, secret);
12
+ }
13
+
14
+ export function forgetSessionSecret(sessionId: string): void {
15
+ secretsBySessionId.delete(sessionId);
16
+ }
17
+
18
+ /** Spread into any Focus-mutating request body alongside session_id -- empty object when no secret is cached for this sessionId (unregistered, or registration hasn't completed yet), matching the daemon's opt-in-armor default. */
19
+ export function sessionSecretField(sessionId: string | undefined): { session_secret?: string } {
20
+ const secret = sessionId ? secretsBySessionId.get(sessionId) : undefined;
21
+ return secret ? { session_secret: secret } : {};
22
+ }
@@ -8,6 +8,7 @@ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
8
8
  import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import { callService } from "./service-client.ts";
10
10
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
11
+ import { sessionSecretField } from "./session-identity.ts";
11
12
  import { showTaskDetails } from "./task-detail-view.ts";
12
13
  import { showTaskGraph } from "./task-graph.ts";
13
14
 
@@ -175,7 +176,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
175
176
  }
176
177
  } else if (choice === "Make active") {
177
178
  try {
178
- const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
179
+ const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
179
180
  emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
180
181
  ctx.ui.notify(`Active: ${action.row.title}`, "info");
181
182
  } catch (error) {
@@ -184,11 +185,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
184
185
  } else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
185
186
  try {
186
187
  if (choice === "Clear focus") {
187
- await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId });
188
+ await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
188
189
  emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
189
190
  } else {
190
191
  const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
191
- const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId });
192
+ const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
192
193
  emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
193
194
  }
194
195
  ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "files": ["src", "extension", "README.md"],
43
43
  "dependencies": {
44
- "beautiful-mermaid": "1.1.3"
44
+ "beautiful-mermaid": "1.1.3",
45
+ "@danypops/daemon-kit": "^0.2.0"
45
46
  }
46
47
  }
@@ -0,0 +1,46 @@
1
+ import { SESSION_IDENTITY_MAX_ROWS } from "../constants.ts";
2
+ import type { Db } from "../db.ts";
3
+ import { inTransaction } from "../db.ts";
4
+ import type { SessionIdentityRecord, SessionIdentityStore } from "../ports/session-identity-store.ts";
5
+
6
+ export class SQLiteSessionIdentityStore implements SessionIdentityStore {
7
+ constructor(private readonly db: Db) {}
8
+
9
+ find(sessionId: string): SessionIdentityRecord | undefined {
10
+ const row = this.db.prepare("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?").get(sessionId) as
11
+ | { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string }
12
+ | null;
13
+ return row ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at } : undefined;
14
+ }
15
+
16
+ upsert(record: SessionIdentityRecord): void {
17
+ inTransaction(this.db, () => {
18
+ this.evictOldestBeyondCap(record.sessionId);
19
+ this.db.prepare(`
20
+ INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
21
+ VALUES (?, ?, ?, ?)
22
+ ON CONFLICT(session_id) DO UPDATE SET secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at
23
+ `).run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
24
+ });
25
+ }
26
+
27
+ remove(sessionId: string): void {
28
+ this.db.prepare("DELETE FROM session_identities WHERE session_id = ?").run(sessionId);
29
+ }
30
+
31
+ touch(sessionId: string, lastSeenAt: string): void {
32
+ this.db.prepare("UPDATE session_identities SET last_seen_at = ? WHERE session_id = ?").run(lastSeenAt, sessionId);
33
+ }
34
+
35
+ count(): number {
36
+ return (this.db.prepare("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
37
+ }
38
+
39
+ /** Bounds distinct registered session identities; evicts the least-recently-seen beyond the cap. Mirrors SQLiteTaskFocusStore.evictOldestBeyondCap exactly. */
40
+ private evictOldestBeyondCap(sessionId: string): void {
41
+ const exists = this.db.prepare("SELECT 1 FROM session_identities WHERE session_id = ?").get(sessionId);
42
+ if (exists) return;
43
+ if (this.count() < SESSION_IDENTITY_MAX_ROWS) return;
44
+ this.db.exec("DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)");
45
+ }
46
+ }
package/src/cli.ts CHANGED
@@ -112,18 +112,20 @@ const USAGE = `Usage:
112
112
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
113
113
  papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
114
114
  papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
115
+ papyrus session register --session-id <id> [--json]
116
+ papyrus session release --session-id <id> [--session-secret <secret>] [--json]
115
117
  papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
116
118
  papyrus tasks plan [--session-id <id>] [--json]
117
119
  papyrus tasks graph [--session-id <id>] [--json]
118
120
  papyrus tasks active [--session-id <id>] [--json]
119
121
  papyrus tasks focused [--session-id <id>] [--json]
120
- papyrus tasks pause [--session-id <id>] [--json]
121
- papyrus tasks unpause [--session-id <id>] [--json]
122
- papyrus tasks clear-focus [--session-id <id>] [--json]
122
+ papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
123
+ papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
124
+ papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
123
125
  papyrus tasks history <id> [--json]
124
126
  papyrus tasks scope [project|all|graph <root-id>] [--json]
125
127
  papyrus tasks assign-project <id> [project-root] [--json]
126
- papyrus tasks focus <id> [--session-id <id>] [--json]
128
+ papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
127
129
  papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
128
130
  papyrus tasks complete <id> [--session-id <id>] [--json]
129
131
  papyrus tasks start <id> [--session-id <id>] [--json]
@@ -142,7 +144,8 @@ const USAGE = `Usage:
142
144
  papyrus tasks set-checklist <id> --checklist-json <json> [--json]
143
145
  papyrus tasks context [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
144
146
 
145
- A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior).`;
147
+ A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior).
148
+ Once a session id is registered ("papyrus session register"), mutating its Focus (focus/pause/unpause/clear-focus) requires the matching "--session-secret"; an unregistered session id is unaffected.`;
146
149
 
147
150
  function usage(): never {
148
151
  console.error(USAGE);
@@ -909,6 +912,33 @@ export async function runLogCli(args: string[], client: TaskCliClient, projectRo
909
912
  throw new Error("log action must be append or query");
910
913
  }
911
914
 
915
+ export async function runSessionIdentityCli(args: string[], client: TaskCliClient): Promise<string> {
916
+ const json = args.includes("--json");
917
+ const positional: string[] = [];
918
+ let sessionId: string | undefined;
919
+ let secret: string | undefined;
920
+ for (let index = 0; index < args.length; index++) {
921
+ const argument = args[index]!;
922
+ if (argument === "--json") continue;
923
+ if (argument === "--session-id") { sessionId = args[++index]; continue; }
924
+ if (argument === "--session-secret") { secret = args[++index]; continue; }
925
+ if (argument.startsWith("--")) throw new Error(`unknown session option ${argument}`);
926
+ positional.push(argument);
927
+ }
928
+ const [action] = positional;
929
+ if (action === "register") {
930
+ if (!sessionId) throw new Error("session register requires --session-id");
931
+ const result = await client.call<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
932
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
933
+ }
934
+ if (action === "release") {
935
+ if (!sessionId) throw new Error("session release requires --session-id");
936
+ const result = await client.call<Record<string, unknown>, { released: boolean }>("session.release", { session_id: sessionId, ...(secret ? { session_secret: secret } : {}) });
937
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
938
+ }
939
+ throw new Error("session action must be register or release");
940
+ }
941
+
912
942
  export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
913
943
  const json = args.includes("--json");
914
944
  const positional: string[] = [];
@@ -975,6 +1005,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
975
1005
  const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
976
1006
  let reason: string | undefined;
977
1007
  let sessionId: string | undefined;
1008
+ let sessionSecret: string | undefined;
978
1009
  let title: string | undefined;
979
1010
  let body: string | undefined;
980
1011
  let status: string | undefined;
@@ -997,6 +1028,11 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
997
1028
  if (!sessionId) throw new Error("--session-id requires a value");
998
1029
  continue;
999
1030
  }
1031
+ if (argument === "--session-secret") {
1032
+ sessionSecret = args[++index];
1033
+ if (!sessionSecret) throw new Error("--session-secret requires a value");
1034
+ continue;
1035
+ }
1000
1036
  if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
1001
1037
  const value = args[++index];
1002
1038
  if (value === undefined) throw new Error(`${argument} requires a value`);
@@ -1046,6 +1082,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1046
1082
  const reasonSupportedActions = new Set(["update", "depend", "undepend", "contain", "uncontain"]);
1047
1083
  if (reason !== undefined && !reasonSupportedActions.has(action ?? "")) throw new Error("--reason is only supported by tasks update, depend, undepend, contain, and uncontain");
1048
1084
  const sessionScope = sessionId ? { session_id: sessionId } : {};
1085
+ // Only meaningful alongside a registered session_id (see session.register); required by the
1086
+ // daemon only for the specific Focus-mutating operations below (focus/pause/unpause/clear_focus)
1087
+ // once that session_id has been armored (see session-identity-service.ts assertAuthorized).
1088
+ const sessionSecretField = sessionSecret ? { session_secret: sessionSecret } : {};
1049
1089
  let result: unknown;
1050
1090
  let human: string;
1051
1091
  switch (action) {
@@ -1067,14 +1107,14 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1067
1107
  case "unpause": {
1068
1108
  if (id) throw new Error(`tasks ${action} accepts no positional arguments`);
1069
1109
  const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
1070
- const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope });
1110
+ const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1071
1111
  result = focus;
1072
1112
  human = `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`;
1073
1113
  break;
1074
1114
  }
1075
1115
  case "clear-focus": {
1076
1116
  if (id) throw new Error("tasks clear-focus accepts no positional arguments");
1077
- const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope });
1117
+ const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1078
1118
  result = cleared;
1079
1119
  human = cleared.cleared ? "Task focus cleared." : "No focused task.";
1080
1120
  break;
@@ -1206,7 +1246,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1206
1246
  }
1207
1247
  case "focus": {
1208
1248
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
1209
- const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope });
1249
+ const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1210
1250
  result = active;
1211
1251
  human = `Active: ${artifactLabel(active)}`;
1212
1252
  break;
@@ -1308,6 +1348,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1308
1348
  console.log(await runLogCli(args.slice(1), client));
1309
1349
  return;
1310
1350
  }
1351
+ if (command === "session") {
1352
+ const client = await connectPapyrusClient();
1353
+ console.log(await runSessionIdentityCli(args.slice(1), client));
1354
+ return;
1355
+ }
1311
1356
  if (command === "migrate") {
1312
1357
  const client = await connectPapyrusClient();
1313
1358
  console.log(await runMigrationCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 12;
10
+ export const SQLITE_SCHEMA_VERSION = 13;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -146,6 +146,8 @@ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
146
146
  export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
147
147
  /** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
148
148
  export const TASK_FOCUS_MAX_SCOPES = 500;
149
+ /** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
150
+ export const SESSION_IDENTITY_MAX_ROWS = 2_000;
149
151
  /** Persisted project and focused-graph Task view bounds. */
150
152
  export const TASK_SCOPE_MAX_TASKS = 1_000;
151
153
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
package/src/db.ts CHANGED
@@ -210,6 +210,12 @@ CREATE TABLE IF NOT EXISTS log_entries (
210
210
  CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
211
211
  CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
212
212
  BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
213
+ CREATE TABLE IF NOT EXISTS session_identities (
214
+ session_id TEXT PRIMARY KEY,
215
+ secret_hash TEXT NOT NULL,
216
+ registered_at TEXT NOT NULL,
217
+ last_seen_at TEXT NOT NULL
218
+ );
213
219
  `;
214
220
 
215
221
  const SEED_SQL = `
@@ -287,6 +293,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
287
293
  { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
288
294
  { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
289
295
  { version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
296
+ { version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
290
297
  ];
291
298
 
292
299
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -637,6 +644,22 @@ export function migrateDb(db: Db): MigrationResult {
637
644
  `);
638
645
  applied.push("remove-discourse");
639
646
  }
647
+ if (schemaVersion(db) === 12) {
648
+ // See domain/session-identity.ts and verify-caller-identity-behind-papyrus-mutation-
649
+ // attribution-koxt: first-touch capability binding for session_id, the one place it is
650
+ // behavior-affecting today (Task Focus). Purely additive -- a session_id that never
651
+ // registers here behaves exactly as before.
652
+ db.exec(`
653
+ CREATE TABLE IF NOT EXISTS session_identities (
654
+ session_id TEXT PRIMARY KEY,
655
+ secret_hash TEXT NOT NULL,
656
+ registered_at TEXT NOT NULL,
657
+ last_seen_at TEXT NOT NULL
658
+ );
659
+ PRAGMA user_version = 13;
660
+ `);
661
+ applied.push("session-identity");
662
+ }
640
663
  if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
641
664
  });
642
665
  if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * domain/session-identity.ts — closes the gap flagged by
3
+ * verify-caller-identity-behind-papyrus-mutation-attribution-koxt: session_id was, until now,
4
+ * pure caller-supplied free text at the RPC boundary, yet scope-task-focus-and-its-
5
+ * tuicontext-injection-to-the-request-8d5n made it load-bearing for *behavior* (Task Focus
6
+ * is keyed by session_id) -- a forged session_id can pause/unpause/clear/redirect a live
7
+ * session's Focus, not merely mislabel history.
8
+ *
9
+ * The real constraint this design works within: Papyrus's daemon authenticates every client
10
+ * with ONE shared bearer token per machine (src/daemon-state.ts) -- every authenticated
11
+ * caller looks identical to the daemon. Checked directly against Bun's public server API:
12
+ * no SO_PEERCRED-equivalent exists for either a TCP or a Unix-socket listener, so real
13
+ * kernel-verified per-process identity is not cheaply achievable; building it would mean FFI
14
+ * against libc, disproportionate to this task. Pi's session ids are uuidv7 (time-ordered, not
15
+ * cryptographically opaque -- confirmed by reading @earendil-works/pi-coding-agent's
16
+ * session-manager.ts source), so they were never meant to double as secrets either.
17
+ *
18
+ * The actual cryptographic primitive (secret generation, hashing, constant-time verify,
19
+ * first-touch registration semantics) is NOT reimplemented here -- it lives in
20
+ * @danypops/daemon-kit's session-identity module. That gap (a shared bearer token cannot
21
+ * distinguish callers; a session id needs a real credential once it becomes behavior-
22
+ * affecting) is generic to every daemon-kit-shaped daemon, not Papyrus-specific -- Papyrus's
23
+ * own daemon.ts/service.ts predates daemon-kit and has not migrated onto it, but this one
24
+ * capability is adopted narrowly regardless (daemon-kit's exports map is designed for
25
+ * exactly this: "a consumer only pulls in what it uses"). This file only wires that generic
26
+ * primitive to Papyrus's own SQLite storage (see adapters/sqlite-session-identity-store.ts)
27
+ * and to Task Focus specifically, the one place session_id is behavior-affecting today.
28
+ *
29
+ * Deliberately "opt-in armor", not a breaking migration: a session_id that was never
30
+ * registered behaves exactly as before (open), so every already-open session and every
31
+ * caller that never calls session.register (bare CLI use of the "global" scope, older
32
+ * Papyrus builds, test fixtures) is unaffected. Every real Pi session becomes armored
33
+ * automatically the moment its extension fires session_start.
34
+ *
35
+ * Explicitly NOT solved by this: a race at first contact (whoever registers a session_id
36
+ * first becomes its legitimate owner) -- an attacker who wins that race before the real
37
+ * session ever registers still prevails. This is a real, disclosed, accepted residual limit,
38
+ * not oversold as "verified identity"; scribe's own prior art never solved caller identity
39
+ * either. Registering as soon as the extension's session_start hook fires (before any
40
+ * Focus-mutating call could plausibly happen) shrinks this window to something small, not
41
+ * zero.
42
+ *
43
+ * actor/source free-text fields are explicitly NOT in scope here -- they remain audit-trail
44
+ * labeling only, never a security boundary, matching the conclusion already reached when this
45
+ * task was deferred from add-a-generic-mutation-event-log-to-the-papyrus-artifactstor-at6h.
46
+ */
47
+
48
+ export function assertValidSessionId(sessionId: string): void {
49
+ if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("session_id is required");
50
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * modules/session-identity.ts — the session-identity domain as a registered Papyrus-native
3
+ * module. Deliberately self-contained: no artifact/task infrastructure imports, matching the
4
+ * precedent set by modules/logs.ts. See domain/session-identity.ts for the design rationale.
5
+ */
6
+ import type { OperationDefinition } from "../module-registry.ts";
7
+ import type { SessionIdentity } from "../session-identity-service.ts";
8
+
9
+ const MODULE_ID = "session-identity";
10
+
11
+ type OperationInput = Record<string, unknown>;
12
+
13
+ function string(input: OperationInput, key: string): string {
14
+ const value = input[key];
15
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
16
+ return value;
17
+ }
18
+
19
+ function optionalString(input: OperationInput, key: string): string | undefined {
20
+ const value = input[key];
21
+ if (value === undefined) return undefined;
22
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
23
+ return value;
24
+ }
25
+
26
+ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
27
+ export const SESSION_IDENTITY_OPERATION_NAMES = ["session.register", "session.release"] as const;
28
+
29
+ /** Registers every session.* operation against one SessionIdentity instance. */
30
+ export function sessionIdentityOperations(sessionIdentity: SessionIdentity): OperationDefinition[] {
31
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
32
+ name, moduleId: MODULE_ID, execute,
33
+ });
34
+ return [
35
+ define("session.register", (input: OperationInput) => sessionIdentity.register(string(input, "session_id"))),
36
+ define("session.release", (input: OperationInput) => sessionIdentity.release(string(input, "session_id"), optionalString(input, "session_secret"))),
37
+ ];
38
+ }
@@ -23,6 +23,7 @@ import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.
23
23
  import type { TaskViewMode } from "../domain/task-scope.ts";
24
24
  import type { OperationDefinition } from "../module-registry.ts";
25
25
  import type { ArtifactStore } from "../ports/artifact-store.ts";
26
+ import type { SessionIdentity } from "../session-identity-service.ts";
26
27
  import { taskContext } from "../task-context.ts";
27
28
  import { projectTaskExecution } from "../task-execution.ts";
28
29
  import { Tasks, type TaskStatus } from "../task-service.ts";
@@ -92,10 +93,17 @@ export const TASKS_OPERATION_NAMES = [
92
93
  "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
93
94
  ] as const;
94
95
 
95
- export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): OperationDefinition[] {
96
+ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
96
97
  const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
97
98
  name, moduleId: MODULE_ID, execute,
98
99
  });
100
+ // Enforced only for the operations where session_id is BEHAVIOR-affecting (it selects
101
+ // which Task Focus row is mutated), not for every session_id-carrying operation -- see
102
+ // domain/session-identity.ts. A session_id with no registered identity passes through
103
+ // unchanged (opt-in armor).
104
+ const guardFocusMutation = (input: OperationInput): void => {
105
+ sessionIdentity.assertAuthorized(eventContext(input).sessionId, optionalString(input, "session_secret"));
106
+ };
99
107
  return [
100
108
  define("tasks.create", (input: OperationInput) => tasks.create({
101
109
  title: string(input, "title"),
@@ -139,10 +147,10 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): Operati
139
147
  )),
140
148
  define("tasks.active", (input: OperationInput) => tasks.active(taskFilter(input))),
141
149
  define("tasks.focused", (input: OperationInput) => tasks.focused(taskFilter(input))),
142
- define("tasks.focus", (input: OperationInput) => tasks.focus(string(input, "id"), eventContext(input))),
143
- define("tasks.pause", (input: OperationInput) => tasks.pauseFocus(eventContext(input))),
144
- define("tasks.unpause", (input: OperationInput) => tasks.unpauseFocus(eventContext(input))),
145
- define("tasks.clear_focus", (input: OperationInput) => tasks.clearFocus(eventContext(input))),
150
+ define("tasks.focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.focus(string(input, "id"), eventContext(input)); }),
151
+ define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input)); }),
152
+ define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input)); }),
153
+ define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }),
146
154
  define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
147
155
  define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
148
156
  define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
@@ -0,0 +1,9 @@
1
+ import type { SessionIdentityRecord, SessionIdentityStore as DaemonKitSessionIdentityStore } from "@danypops/daemon-kit/session-identity";
2
+
3
+ /**
4
+ * Papyrus's persistence port for @danypops/daemon-kit's storage-agnostic session-identity
5
+ * primitive -- re-exported under this project's own port naming convention (src/ports/*)
6
+ * rather than importing the daemon-kit interface name directly at every call site.
7
+ */
8
+ export type SessionIdentityStore = DaemonKitSessionIdentityStore;
9
+ export type { SessionIdentityRecord };
package/src/service.ts CHANGED
@@ -8,6 +8,7 @@ import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-s
8
8
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
9
9
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
10
10
  import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
11
+ import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
11
12
  import type { CreateArtifactInput } from "./domain/artifact.ts";
12
13
  import { AuthorityRegistry, AuthorizedArtifactWriter, type AuthorityClaim } from "./authority-registry.ts";
13
14
  import type { TaskEventContext } from "./domain/task-event.ts";
@@ -24,6 +25,7 @@ import {
24
25
  import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
25
26
  import { Logs } from "./log-service.ts";
26
27
  import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
28
+ import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
27
29
  import { OperationRegistry } from "./module-registry.ts";
28
30
  import { docsOperations, DOCS_OPERATION_NAMES } from "./modules/docs.ts";
29
31
  import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./modules/graph-projection.ts";
@@ -31,6 +33,7 @@ import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
31
33
  import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
32
34
  import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
33
35
  import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
36
+ import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
34
37
  import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
35
38
 
36
39
  /**
@@ -68,6 +71,7 @@ export const EXPECTED_OPERATION_NAMES = [
68
71
  ...SKILLS_OPERATION_NAMES,
69
72
  ...GRAPH_PROJECTION_OPERATION_NAMES,
70
73
  ...LOGS_OPERATION_NAMES,
74
+ ...SESSION_IDENTITY_OPERATION_NAMES,
71
75
  ] as const;
72
76
 
73
77
  export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
@@ -77,6 +81,7 @@ type OperationHandler = (input: OperationInput) => unknown;
77
81
  export class UnknownOperationError extends Error {}
78
82
  export class MigrationRequiredError extends Error {}
79
83
  export class PayloadTooLargeError extends Error {}
84
+ export { InvalidSessionSecretError };
80
85
 
81
86
  function string(input: OperationInput, key: string): string {
82
87
  const value = input[key];
@@ -366,6 +371,8 @@ function handlers(
366
371
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
367
372
  "logs.append": forwardToModule("logs.append"),
368
373
  "logs.query": forwardToModule("logs.query"),
374
+ "session.register": forwardToModule("session.register"),
375
+ "session.release": forwardToModule("session.release"),
369
376
  };
370
377
  }
371
378
 
@@ -381,11 +388,13 @@ export function createPapyrusService(path: string): PapyrusService {
381
388
  const projections = new SQLiteGraphProjectionStore(db);
382
389
  const artifactScopes = new SQLiteArtifactScopeStore(db);
383
390
  const logs = new Logs(new SQLiteLogStore(db));
391
+ const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
384
392
  const authority = createAuthorityRegistry();
385
393
  const moduleRegistry = new OperationRegistry();
386
394
  moduleRegistry.registerAll(notesOperations(notes));
387
395
  moduleRegistry.registerAll(logsOperations(logs));
388
- moduleRegistry.registerAll(tasksOperations(tasks, artifacts));
396
+ moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
397
+ moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
389
398
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
390
399
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
391
400
  moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
@@ -467,7 +476,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
467
476
  }
468
477
  return json({ result: await deps.service.execute(body.op, input as OperationInput) });
469
478
  } catch (error) {
470
- const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : 400;
479
+ const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : error instanceof InvalidSessionSecretError ? 403 : 400;
471
480
  return json({ error: error instanceof Error ? error.message : String(error) }, { status });
472
481
  }
473
482
  }
@@ -0,0 +1,55 @@
1
+ import { isSessionRegistered, registerSessionIdentity, releaseSessionIdentity, verifySessionSecret } from "@danypops/daemon-kit/session-identity";
2
+ import { assertValidSessionId } from "./domain/session-identity.ts";
3
+ import type { SessionIdentityStore } from "./ports/session-identity-store.ts";
4
+
5
+ export interface RegisterSessionIdentityResult {
6
+ sessionId: string;
7
+ secret: string;
8
+ }
9
+
10
+ /** Thrown by assertSessionAuthorized when a session_id has a registered identity but the caller did not present a matching session_secret. A distinguishable type so service.ts can map it to HTTP 403, separate from generic validation's 400. */
11
+ export class InvalidSessionSecretError extends Error {}
12
+
13
+ /**
14
+ * Thin Papyrus-side wrapper over @danypops/daemon-kit's storage-agnostic session-identity
15
+ * primitive: validates input shape, binds it to Papyrus's own SQLite-backed store, and
16
+ * exposes the exact three operations Task Focus enforcement needs (see
17
+ * assertAuthorizedForFocus in src/modules/tasks.ts). See domain/session-identity.ts for the
18
+ * full design rationale and its explicitly accepted residual limits.
19
+ */
20
+ export class SessionIdentity {
21
+ constructor(private readonly store: SessionIdentityStore) {}
22
+
23
+ register(sessionId: string): RegisterSessionIdentityResult {
24
+ assertValidSessionId(sessionId);
25
+ return registerSessionIdentity(this.store, sessionId);
26
+ }
27
+
28
+ release(sessionId: string, secret: string | undefined): { released: boolean } {
29
+ assertValidSessionId(sessionId);
30
+ const wasRegistered = isSessionRegistered(this.store, sessionId);
31
+ releaseSessionIdentity(this.store, sessionId, secret);
32
+ return { released: wasRegistered && !isSessionRegistered(this.store, sessionId) };
33
+ }
34
+
35
+ isRegistered(sessionId: string): boolean {
36
+ return isSessionRegistered(this.store, sessionId);
37
+ }
38
+
39
+ verify(sessionId: string, secret: string | undefined): boolean {
40
+ return verifySessionSecret(this.store, sessionId, secret);
41
+ }
42
+
43
+ /**
44
+ * The enforcement point for a Focus-mutating operation: opt-in armor, matching
45
+ * domain/session-identity.ts's design -- a sessionId with no registered identity passes
46
+ * through unauthenticated exactly as before (undefined sessionId included, since that maps
47
+ * to Task Focus's "global" scope, never armored). Only once a sessionId is registered does
48
+ * a matching session_secret become mandatory.
49
+ */
50
+ assertAuthorized(sessionId: string | undefined, secret: string | undefined): void {
51
+ if (sessionId === undefined) return;
52
+ if (!this.isRegistered(sessionId)) return;
53
+ if (!this.verify(sessionId, secret)) throw new InvalidSessionSecretError(`session "${sessionId}" is registered; a valid session_secret is required to mutate its Task Focus`);
54
+ }
55
+ }