pi-sessions 0.8.0 → 0.9.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 (104) hide show
  1. package/README.md +70 -37
  2. package/extensions/pi-sessions.ts +125 -0
  3. package/extensions/session-ask/agent.ts +34 -14
  4. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  5. package/extensions/session-ask/navigate.ts +3 -1
  6. package/extensions/session-ask/presenter.ts +23 -0
  7. package/extensions/session-ask/renderer.ts +63 -0
  8. package/extensions/session-ask/tool-contract.ts +27 -0
  9. package/extensions/session-ask/view-model.ts +18 -0
  10. package/extensions/session-auto-title/generate.ts +18 -18
  11. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +17 -18
  14. package/extensions/session-auto-title/wizard.ts +12 -11
  15. package/extensions/session-handoff/board-view-model.ts +181 -0
  16. package/extensions/session-handoff/board.ts +663 -0
  17. package/extensions/session-handoff/bootstrap.ts +174 -0
  18. package/extensions/session-handoff/extract.ts +101 -122
  19. package/extensions/session-handoff/install.ts +225 -0
  20. package/extensions/session-handoff/kickoff.ts +92 -0
  21. package/extensions/session-handoff/launch/backend.ts +16 -0
  22. package/extensions/session-handoff/launch/deferred.ts +20 -0
  23. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  24. package/extensions/session-handoff/launch/resolution.ts +57 -0
  25. package/extensions/session-handoff/launch/shell.ts +7 -0
  26. package/extensions/session-handoff/launch/tmux.ts +31 -0
  27. package/extensions/session-handoff/launch-options.ts +61 -0
  28. package/extensions/session-handoff/launch-target.ts +91 -0
  29. package/extensions/session-handoff/metadata.ts +152 -63
  30. package/extensions/session-handoff/model.ts +58 -0
  31. package/extensions/session-handoff/query.ts +0 -1
  32. package/extensions/session-handoff/receipt.ts +96 -0
  33. package/extensions/session-handoff/review.ts +2 -28
  34. package/extensions/session-handoff/spawn.ts +76 -181
  35. package/extensions/session-handoff/strong-modal.ts +13 -0
  36. package/extensions/session-handoff/tool-contract.ts +12 -0
  37. package/extensions/session-handoff/tool-presenter.ts +35 -0
  38. package/extensions/session-handoff/tool-renderer.ts +66 -0
  39. package/extensions/session-handoff/tool-schema.ts +54 -0
  40. package/extensions/session-handoff/tool-view-model.ts +49 -0
  41. package/extensions/session-handoff/tool.ts +206 -0
  42. package/extensions/session-handoff/ui.ts +66 -0
  43. package/extensions/session-hooks/install.ts +75 -0
  44. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  45. package/extensions/session-messaging/broker/process.ts +21 -28
  46. package/extensions/session-messaging/install.ts +78 -0
  47. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  48. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  49. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  50. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  51. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  52. package/extensions/session-messaging/pi/renderer.ts +19 -12
  53. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  54. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  55. package/extensions/session-messaging/pi/service.ts +262 -81
  56. package/extensions/session-messaging/pi/tools.ts +184 -40
  57. package/extensions/session-search/extract.ts +60 -14
  58. package/extensions/session-search/hooks.ts +10 -13
  59. package/extensions/session-search/install.ts +373 -0
  60. package/extensions/session-search/renderer.ts +195 -0
  61. package/extensions/session-search/tool-contract.ts +51 -0
  62. package/extensions/shared/composition.ts +20 -0
  63. package/extensions/shared/errors.ts +3 -0
  64. package/extensions/shared/model-resolution.ts +73 -0
  65. package/extensions/shared/model-runtime.ts +33 -0
  66. package/extensions/shared/model.ts +7 -10
  67. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  68. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  69. package/extensions/shared/rendering/theme.ts +5 -0
  70. package/extensions/shared/session-broker/client.ts +30 -29
  71. package/extensions/shared/session-broker/protocol.ts +128 -10
  72. package/extensions/shared/session-index/common.ts +5 -5
  73. package/extensions/shared/session-index/lineage.ts +0 -3
  74. package/extensions/shared/session-index/schema.ts +0 -1
  75. package/extensions/shared/session-index/scoring.ts +1 -2
  76. package/extensions/shared/session-index/search.ts +11 -4
  77. package/extensions/shared/session-index/store.ts +4 -9
  78. package/extensions/shared/settings.ts +76 -38
  79. package/extensions/shared/text.ts +5 -0
  80. package/extensions/shared/thinking-levels.ts +22 -0
  81. package/extensions/shared/tmux.ts +205 -0
  82. package/extensions/subagents/cancel.ts +111 -0
  83. package/extensions/subagents/classify.ts +52 -0
  84. package/extensions/subagents/install.ts +344 -0
  85. package/extensions/subagents/launch-target.ts +75 -0
  86. package/extensions/subagents/ledger.ts +217 -0
  87. package/extensions/subagents/reconcile.ts +386 -0
  88. package/extensions/subagents/report-message-presenter.ts +36 -0
  89. package/extensions/subagents/report-message-renderer.ts +21 -0
  90. package/extensions/subagents/report-message-view-model.ts +29 -0
  91. package/extensions/subagents/report.ts +159 -0
  92. package/extensions/subagents/roster.ts +262 -0
  93. package/extensions/subagents/wake.ts +173 -0
  94. package/images/handoff-board-subagents.png +0 -0
  95. package/images/handoff-board-user-sessions.png +0 -0
  96. package/images/handoff-subagent-report.png +0 -0
  97. package/images/session-handoff-tool.png +0 -0
  98. package/package.json +10 -16
  99. package/extensions/session-handoff.ts +0 -741
  100. package/extensions/session-hooks.ts +0 -75
  101. package/extensions/session-messaging/pi/message-view.ts +0 -131
  102. package/extensions/session-messaging.ts +0 -30
  103. package/extensions/session-search.ts +0 -419
  104. package/extensions/shared/session-broker/active.ts +0 -26
@@ -30,7 +30,6 @@ const SESSION_LINEAGE_QUERY_ROW_SCHEMA = Type.Object({
30
30
  parentSessionId: NULLABLE_STRING_SCHEMA,
31
31
  sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
32
32
  handoffGoal: NULLABLE_STRING_SCHEMA,
33
- handoffNextTask: NULLABLE_STRING_SCHEMA,
34
33
  });
35
34
 
36
35
  const SESSION_RELATED_QUERY_ROW_SCHEMA = Type.Intersect([
@@ -66,7 +65,6 @@ function sessionLineageColumns(alias?: string): string {
66
65
  `${prefix}parent_session_id as parentSessionId`,
67
66
  `${prefix}session_origin as sessionOrigin`,
68
67
  `${prefix}handoff_goal as handoffGoal`,
69
- `${prefix}handoff_next_task as handoffNextTask`,
70
68
  ].join(",\n ");
71
69
  }
72
70
 
@@ -85,7 +83,6 @@ function buildSessionLineageRow(
85
83
  parentSessionId: row.parentSessionId ?? undefined,
86
84
  sessionOrigin: row.sessionOrigin ?? undefined,
87
85
  handoffGoal: row.handoffGoal ?? undefined,
88
- handoffNextTask: row.handoffNextTask ?? undefined,
89
86
  };
90
87
  }
91
88
 
@@ -47,7 +47,6 @@ export function initializeSchema(db: SessionIndexDatabase): void {
47
47
  parent_session_id TEXT,
48
48
  session_origin TEXT,
49
49
  handoff_goal TEXT,
50
- handoff_next_task TEXT,
51
50
  indexed_file_size INTEGER,
52
51
  indexed_file_mtime_ms INTEGER,
53
52
  indexed_file_anchor TEXT,
@@ -6,8 +6,7 @@ const RECENCY_HALF_LIFE_DAYS = 21;
6
6
 
7
7
  const TEXT_SOURCE_WEIGHTS = new Map<string, number>([
8
8
  ["session_name", 360],
9
- ["handoff_next_task", 300],
10
- ["handoff_goal", 260],
9
+ ["handoff_goal", 300],
11
10
  ["user_text", 180],
12
11
  ["assistant_text", 150],
13
12
  ["bash_command", 140],
@@ -19,6 +19,7 @@ import {
19
19
  type SearchSessionsParams,
20
20
  type SearchSort,
21
21
  type SessionIndexDatabase,
22
+ type SessionKind,
22
23
  type SessionLineageRelation,
23
24
  sanitizeFilterValues,
24
25
  tokenizeSearchText,
@@ -51,7 +52,6 @@ const SESSION_LIST_ROW_SCHEMA = Type.Object({
51
52
  firstUserPrompt: NULLABLE_STRING_SCHEMA,
52
53
  sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
53
54
  handoffGoal: NULLABLE_STRING_SCHEMA,
54
- handoffNextTask: NULLABLE_STRING_SCHEMA,
55
55
  });
56
56
 
57
57
  type SessionListRow = Static<typeof SESSION_LIST_ROW_SCHEMA>;
@@ -88,6 +88,7 @@ interface SearchWhereFilters {
88
88
  repo: string | undefined;
89
89
  touched: string[];
90
90
  changed: string[];
91
+ kind: SessionKind | undefined;
91
92
  excludeSessionIds: string[];
92
93
  includeSessionIds: string[] | undefined;
93
94
  }
@@ -202,6 +203,7 @@ function buildSearchFilters(params: SearchSessionsParams): SearchFilters {
202
203
  repo: params.repo?.trim(),
203
204
  touched: sanitizeFilterValues(params.touched),
204
205
  changed: sanitizeFilterValues(params.changed),
206
+ kind: params.kind,
205
207
  limit: normalizeResultLimit(params.limit),
206
208
  sort: normalizeSort(params.sort),
207
209
  query: params.query?.trim() || undefined,
@@ -258,8 +260,7 @@ function getFilteredSessionCandidates(
258
260
  s.parent_session_id as parentSessionId,
259
261
  s.first_user_prompt as firstUserPrompt,
260
262
  s.session_origin as sessionOrigin,
261
- s.handoff_goal as handoffGoal,
262
- s.handoff_next_task as handoffNextTask
263
+ s.handoff_goal as handoffGoal
263
264
  FROM sessions s
264
265
  ${where.sql}
265
266
  ORDER BY s.modified_ts ${orderDirection}
@@ -445,6 +446,7 @@ function createSessionIdWhereFilters(sessionIds: string[] | undefined): SearchWh
445
446
  repo: undefined,
446
447
  touched: [],
447
448
  changed: [],
449
+ kind: undefined,
448
450
  excludeSessionIds: [],
449
451
  includeSessionIds: sessionIds === undefined ? undefined : sanitizeFilterValues(sessionIds),
450
452
  };
@@ -657,7 +659,6 @@ function buildSearchResult(
657
659
  firstUserPrompt: row.firstUserPrompt ?? undefined,
658
660
  sessionOrigin: row.sessionOrigin ?? undefined,
659
661
  handoffGoal: row.handoffGoal ?? undefined,
660
- handoffNextTask: row.handoffNextTask ?? undefined,
661
662
  relation: relationBySessionId.get(row.sessionId),
662
663
  snippet: "",
663
664
  evidence: [...fileEvidence],
@@ -759,6 +760,12 @@ function buildSessionWhereClause(alias: string, filters: SearchWhereFilters): Sq
759
760
  args.push(filters.cwd, filters.cwdLike ?? `${escapeLikePrefix(filters.cwd)}%`);
760
761
  }
761
762
 
763
+ if (filters.kind === "subagent") {
764
+ conditions.push(`${alias}.session_origin = 'subagent'`);
765
+ } else if (filters.kind === "user") {
766
+ conditions.push(`(${alias}.session_origin IS NULL OR ${alias}.session_origin <> 'subagent')`);
767
+ }
768
+
762
769
  if (filters.excludeSessionIds.length > 0) {
763
770
  conditions.push(
764
771
  `${alias}.session_id NOT IN (${filters.excludeSessionIds.map(() => "?").join(", ")})`,
@@ -27,7 +27,6 @@ const SESSION_ROW_QUERY_SCHEMA = Type.Object({
27
27
  parentSessionId: NULLABLE_STRING_SCHEMA,
28
28
  sessionOrigin: Type.Union([SESSION_ORIGIN_SCHEMA, Type.Null()]),
29
29
  handoffGoal: NULLABLE_STRING_SCHEMA,
30
- handoffNextTask: NULLABLE_STRING_SCHEMA,
31
30
  indexedFileSize: Type.Union([Type.Number(), Type.Null()]),
32
31
  indexedFileMtimeMs: Type.Union([Type.Number(), Type.Null()]),
33
32
  indexedFileAnchor: NULLABLE_STRING_SCHEMA,
@@ -49,7 +48,6 @@ function sessionRowBindings(row: SessionRow, indexSource: string) {
49
48
  row.parentSessionId ?? null,
50
49
  row.sessionOrigin ?? null,
51
50
  row.handoffGoal ?? null,
52
- row.handoffNextTask ?? null,
53
51
  row.indexedFileSize ?? null,
54
52
  row.indexedFileMtimeMs ?? null,
55
53
  row.indexedFileAnchor ?? null,
@@ -70,10 +68,10 @@ export function insertSession(
70
68
  session_id, session_path, session_name, first_user_prompt, cwd, repo_roots_json,
71
69
  created_ts, modified_ts, message_count, entry_count,
72
70
  parent_session_path, parent_session_id, session_origin,
73
- handoff_goal, handoff_next_task,
71
+ handoff_goal,
74
72
  indexed_file_size, indexed_file_mtime_ms, indexed_file_anchor,
75
73
  index_version, indexed_at_ts, index_source
76
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
75
  `,
78
76
  ).run(...sessionRowBindings(row, indexSource));
79
77
  syncSessionRepoRoots(db, row);
@@ -90,10 +88,10 @@ export function upsertSession(
90
88
  session_id, session_path, session_name, first_user_prompt, cwd, repo_roots_json,
91
89
  created_ts, modified_ts, message_count, entry_count,
92
90
  parent_session_path, parent_session_id, session_origin,
93
- handoff_goal, handoff_next_task,
91
+ handoff_goal,
94
92
  indexed_file_size, indexed_file_mtime_ms, indexed_file_anchor,
95
93
  index_version, indexed_at_ts, index_source
96
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
94
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
97
95
  ON CONFLICT(session_id) DO UPDATE SET
98
96
  session_path = excluded.session_path,
99
97
  session_name = excluded.session_name,
@@ -108,7 +106,6 @@ export function upsertSession(
108
106
  parent_session_id = excluded.parent_session_id,
109
107
  session_origin = excluded.session_origin,
110
108
  handoff_goal = excluded.handoff_goal,
111
- handoff_next_task = excluded.handoff_next_task,
112
109
  indexed_file_size = excluded.indexed_file_size,
113
110
  indexed_file_mtime_ms = excluded.indexed_file_mtime_ms,
114
111
  indexed_file_anchor = excluded.indexed_file_anchor,
@@ -142,7 +139,6 @@ export function getSessionRowByPath(
142
139
  parent_session_id as parentSessionId,
143
140
  session_origin as sessionOrigin,
144
141
  handoff_goal as handoffGoal,
145
- handoff_next_task as handoffNextTask,
146
142
  indexed_file_size as indexedFileSize,
147
143
  indexed_file_mtime_ms as indexedFileMtimeMs,
148
144
  indexed_file_anchor as indexedFileAnchor
@@ -179,7 +175,6 @@ function buildSessionRow(row: Static<typeof SESSION_ROW_QUERY_SCHEMA>): SessionR
179
175
  parentSessionId: row.parentSessionId ?? undefined,
180
176
  sessionOrigin: row.sessionOrigin ?? undefined,
181
177
  handoffGoal: row.handoffGoal ?? undefined,
182
- handoffNextTask: row.handoffNextTask ?? undefined,
183
178
  indexedFileSize: row.indexedFileSize ?? undefined,
184
179
  indexedFileMtimeMs: row.indexedFileMtimeMs ?? undefined,
185
180
  indexedFileAnchor: row.indexedFileAnchor ?? undefined,
@@ -4,15 +4,39 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
4
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
5
5
  import type { KeyId } from "@earendil-works/pi-tui";
6
6
  import { type Static, Type } from "typebox";
7
+ import { isThinkingLevel } from "./thinking-levels.ts";
7
8
  import { parseTypeBoxValue } from "./typebox.ts";
8
9
 
9
10
  export const DEFAULT_AUTO_TITLE_REFRESH_TURNS = 4;
10
11
  export const DEFAULT_AUTO_TITLE_TIMEOUT_SECONDS = 15;
11
12
  export const DEFAULT_AUTO_TITLE_PROMPT = `Name this coding session (under 80 chars). Be specific to what is being discussed. Your exact output will be displayed to the user, so make sure that it contains ONLY the title itself and nothing else.`;
12
13
  const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
14
+ messaging: Type.Optional(
15
+ Type.Object({
16
+ enable: Type.Optional(Type.Boolean()),
17
+ }),
18
+ ),
19
+ subagents: Type.Optional(
20
+ Type.Object({
21
+ enable: Type.Optional(Type.Boolean()),
22
+ maxDepth: Type.Optional(Type.Integer({ minimum: 0 })),
23
+ }),
24
+ ),
13
25
  handoff: Type.Optional(
14
26
  Type.Object({
27
+ enable: Type.Optional(Type.Boolean()),
15
28
  pickerShortcut: Type.Optional(Type.String()),
29
+ persistRuns: Type.Optional(Type.Boolean()),
30
+ deferred: Type.Optional(
31
+ Type.Object({
32
+ copyToClipboard: Type.Optional(Type.Boolean()),
33
+ }),
34
+ ),
35
+ }),
36
+ ),
37
+ search: Type.Optional(
38
+ Type.Object({
39
+ enable: Type.Optional(Type.Boolean()),
16
40
  }),
17
41
  ),
18
42
  index: Type.Optional(
@@ -22,6 +46,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
22
46
  ),
23
47
  autoTitle: Type.Optional(
24
48
  Type.Object({
49
+ enable: Type.Optional(Type.Boolean()),
25
50
  refreshTurns: Type.Optional(Type.Integer({ minimum: 1 })),
26
51
  timeoutSecs: Type.Optional(Type.Integer({ minimum: 1 })),
27
52
  model: Type.Optional(Type.String()),
@@ -31,29 +56,24 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
31
56
  ),
32
57
  ask: Type.Optional(
33
58
  Type.Object({
59
+ enable: Type.Optional(Type.Boolean()),
34
60
  model: Type.Optional(Type.String()),
35
61
  thinkingLevel: Type.Optional(Type.String()),
36
62
  persistRuns: Type.Optional(Type.Boolean()),
37
63
  }),
38
64
  ),
65
+ hooks: Type.Optional(
66
+ Type.Object({
67
+ enable: Type.Optional(Type.Boolean()),
68
+ }),
69
+ ),
39
70
  });
40
71
  const ROOT_SETTINGS_SCHEMA = Type.Object({
41
72
  sessions: Type.Optional(SESSION_FILE_SETTINGS_SCHEMA),
42
73
  });
43
74
 
44
- export class ModelReference {
45
- constructor(
46
- readonly provider: string,
47
- readonly modelId: string,
48
- ) {}
49
-
50
- toString(): string {
51
- return `${this.provider}/${this.modelId}`;
52
- }
53
- }
54
-
55
75
  export interface AgentModelSettings {
56
- model?: ModelReference | undefined;
76
+ model?: string | undefined;
57
77
  thinkingLevel?: ThinkingLevel | undefined;
58
78
  }
59
79
 
@@ -67,9 +87,27 @@ export interface AskSettings extends AgentModelSettings {
67
87
  persistRuns: boolean;
68
88
  }
69
89
 
90
+ export interface FeatureToggles {
91
+ messaging: boolean;
92
+ subagents: boolean;
93
+ handoff: boolean;
94
+ search: boolean;
95
+ ask: boolean;
96
+ autoTitle: boolean;
97
+ hooks: boolean;
98
+ }
99
+
70
100
  export interface SessionSettings {
101
+ features: FeatureToggles;
102
+ subagents: {
103
+ maxDepth: number;
104
+ };
71
105
  handoff: {
72
106
  pickerShortcut: KeyId;
107
+ persistRuns: boolean;
108
+ deferred: {
109
+ copyToClipboard: boolean;
110
+ };
73
111
  };
74
112
  index: {
75
113
  path: string;
@@ -92,6 +130,10 @@ export function getDefaultSessionAskRunsDir(): string {
92
130
  return path.join(getDefaultIndexDir(), "session-ask");
93
131
  }
94
132
 
133
+ export function getDefaultHandoffRunsDir(): string {
134
+ return path.join(getDefaultIndexDir(), "session-handoff");
135
+ }
136
+
95
137
  function expandHome(rawPath: string): string {
96
138
  if (rawPath === "~") {
97
139
  return os.homedir();
@@ -127,33 +169,9 @@ function normalizePickerShortcut(value: string | undefined): KeyId {
127
169
  return (trimmed ? trimmed : "alt+o") as KeyId;
128
170
  }
129
171
 
130
- export function parseModelReference(value: string | undefined): ModelReference | undefined {
131
- const trimmed = value?.trim();
132
- if (!trimmed) {
133
- return undefined;
134
- }
135
-
136
- const slashIndex = trimmed.indexOf("/");
137
- if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
138
- return undefined;
139
- }
140
-
141
- return new ModelReference(trimmed.slice(0, slashIndex), trimmed.slice(slashIndex + 1));
142
- }
143
-
144
172
  function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
145
173
  const trimmed = value?.trim();
146
- switch (trimmed) {
147
- case "off":
148
- case "minimal":
149
- case "low":
150
- case "medium":
151
- case "high":
152
- case "xhigh":
153
- return trimmed;
154
- default:
155
- return undefined;
156
- }
174
+ return isThinkingLevel(trimmed) ? trimmed : undefined;
157
175
  }
158
176
 
159
177
  function resolveAgentModelSettings(
@@ -164,7 +182,7 @@ function resolveAgentModelSettings(
164
182
  }
165
183
  | undefined,
166
184
  ): AgentModelSettings {
167
- const model = parseModelReference(value?.model);
185
+ const model = value?.model?.trim() || undefined;
168
186
  const thinkingLevel = parseThinkingLevel(value?.thinkingLevel);
169
187
  return {
170
188
  ...(model ? { model } : {}),
@@ -183,12 +201,32 @@ function loadSessionFileSettings(): SessionFileSettings {
183
201
  return parsed.sessions ?? {};
184
202
  }
185
203
 
204
+ function resolveFeatureToggles(value: SessionFileSettings): FeatureToggles {
205
+ return {
206
+ messaging: value.messaging?.enable ?? true,
207
+ subagents: value.subagents?.enable ?? true,
208
+ handoff: value.handoff?.enable ?? true,
209
+ search: value.search?.enable ?? true,
210
+ ask: value.ask?.enable ?? true,
211
+ autoTitle: value.autoTitle?.enable ?? true,
212
+ hooks: value.hooks?.enable ?? true,
213
+ };
214
+ }
215
+
186
216
  function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSettings {
187
217
  const indexDir = normalizeIndexDir(fileSettings.index?.dir);
188
218
 
189
219
  return {
220
+ features: resolveFeatureToggles(fileSettings),
221
+ subagents: {
222
+ maxDepth: fileSettings.subagents?.maxDepth ?? 2,
223
+ },
190
224
  handoff: {
191
225
  pickerShortcut: normalizePickerShortcut(fileSettings.handoff?.pickerShortcut),
226
+ persistRuns: fileSettings.handoff?.persistRuns ?? false,
227
+ deferred: {
228
+ copyToClipboard: fileSettings.handoff?.deferred?.copyToClipboard ?? true,
229
+ },
192
230
  },
193
231
  index: {
194
232
  path: path.join(indexDir, "index.sqlite"),
@@ -18,6 +18,11 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
18
18
  return typeof value === "object" && value !== null;
19
19
  }
20
20
 
21
+ export function normalizeOptionalText(value: string | undefined): string | undefined {
22
+ const normalized = value?.trim();
23
+ return normalized || undefined;
24
+ }
25
+
21
26
  export function truncateInline(value: string, maxChars: number): string {
22
27
  const cleaned = value.replace(/\s+/g, " ").trim();
23
28
  if (cleaned.length <= maxChars) {
@@ -0,0 +1,22 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+
3
+ // Pi exports the ThinkingLevel type but no runtime list of its members
4
+ // (getSupportedThinkingLevels requires a concrete model). This object is the
5
+ // one local runtime definition; Record makes upstream additions a type error.
6
+ const THINKING_LEVELS_BY_NAME = {
7
+ off: true,
8
+ minimal: true,
9
+ low: true,
10
+ medium: true,
11
+ high: true,
12
+ xhigh: true,
13
+ max: true,
14
+ } satisfies Record<ThinkingLevel, true>;
15
+
16
+ export const THINKING_LEVELS = Object.freeze(
17
+ Object.keys(THINKING_LEVELS_BY_NAME) as ThinkingLevel[],
18
+ );
19
+
20
+ export function isThinkingLevel(value: string | undefined): value is ThinkingLevel {
21
+ return value !== undefined && Object.hasOwn(THINKING_LEVELS_BY_NAME, value);
22
+ }
@@ -0,0 +1,205 @@
1
+ import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
2
+
3
+ const TMUX_COMMAND = "tmux";
4
+ const TMUX_TIMEOUT_MS = 15_000;
5
+ const SESSION_ID_OPTION = "@pi_session_id";
6
+ const WINDOW_FORMAT = "#{window_id}\t#{window_name}\t#{@pi_session_id}";
7
+
8
+ export interface TmuxWindow {
9
+ windowId: string;
10
+ name: string;
11
+ piSessionId: string;
12
+ }
13
+
14
+ export interface CreateTmuxWindowOptions {
15
+ tmuxSession: string;
16
+ name: string;
17
+ cwd: string;
18
+ command: string;
19
+ piSessionId: string;
20
+ }
21
+
22
+ export interface TmuxExecutor {
23
+ exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
24
+ }
25
+
26
+ export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
27
+ return Boolean(env.TMUX);
28
+ }
29
+
30
+ export function tmuxSessionName(parentSessionId: string): string {
31
+ const hex = parentSessionId.replaceAll("-", "").match(/^[0-9a-fA-F]+/)?.[0] ?? "";
32
+ if (hex.length < 8) {
33
+ throw new Error(`Cannot derive tmux session name from session id: ${parentSessionId}`);
34
+ }
35
+ return `pi-${hex.slice(0, 12).toLowerCase()}`;
36
+ }
37
+
38
+ export async function isTmuxInstalled(executor: TmuxExecutor, cwd: string): Promise<boolean> {
39
+ const result = await executor.exec(TMUX_COMMAND, ["-V"], { cwd, timeout: TMUX_TIMEOUT_MS });
40
+ return result.code === 0;
41
+ }
42
+
43
+ export async function listTmuxWindows(
44
+ executor: TmuxExecutor,
45
+ tmuxSession: string,
46
+ ): Promise<TmuxWindow[]> {
47
+ const result = await executor.exec(
48
+ TMUX_COMMAND,
49
+ ["list-windows", "-t", tmuxSession, "-F", WINDOW_FORMAT],
50
+ { timeout: TMUX_TIMEOUT_MS },
51
+ );
52
+ if (result.code !== 0) {
53
+ if (isMissingSession(result)) {
54
+ return [];
55
+ }
56
+ throw tmuxError("list windows", result);
57
+ }
58
+
59
+ return result.stdout
60
+ .split("\n")
61
+ .filter(Boolean)
62
+ .map(parseWindow)
63
+ .filter((window): window is TmuxWindow => window !== undefined);
64
+ }
65
+
66
+ export async function hasAttachedTmuxClients(
67
+ executor: TmuxExecutor,
68
+ tmuxSession: string,
69
+ ): Promise<boolean> {
70
+ const result = await executor.exec(
71
+ TMUX_COMMAND,
72
+ ["list-clients", "-t", tmuxSession, "-F", "#{client_name}"],
73
+ { timeout: TMUX_TIMEOUT_MS },
74
+ );
75
+ if (result.code !== 0) {
76
+ if (isMissingSession(result)) {
77
+ return false;
78
+ }
79
+ throw tmuxError("list attached clients", result);
80
+ }
81
+ return result.stdout.split("\n").some((line) => line.trim().length > 0);
82
+ }
83
+
84
+ export async function createTmuxWindow(
85
+ executor: TmuxExecutor,
86
+ options: CreateTmuxWindowOptions,
87
+ ): Promise<TmuxWindow> {
88
+ const existing = (await listTmuxWindows(executor, options.tmuxSession)).find(
89
+ (window) => window.piSessionId === options.piSessionId,
90
+ );
91
+ if (existing) {
92
+ return existing;
93
+ }
94
+
95
+ const sessionExists = await hasTmuxSession(executor, options.tmuxSession);
96
+ const args = sessionExists
97
+ ? [
98
+ "new-window",
99
+ "-d",
100
+ "-P",
101
+ "-F",
102
+ "#{window_id}",
103
+ "-t",
104
+ options.tmuxSession,
105
+ "-n",
106
+ options.name,
107
+ "-c",
108
+ options.cwd,
109
+ options.command,
110
+ ]
111
+ : [
112
+ "new-session",
113
+ "-d",
114
+ "-P",
115
+ "-F",
116
+ "#{window_id}",
117
+ "-s",
118
+ options.tmuxSession,
119
+ "-n",
120
+ options.name,
121
+ "-c",
122
+ options.cwd,
123
+ options.command,
124
+ ];
125
+ const created = await executor.exec(TMUX_COMMAND, args, {
126
+ cwd: options.cwd,
127
+ timeout: TMUX_TIMEOUT_MS,
128
+ });
129
+ if (created.code !== 0) {
130
+ throw tmuxError("create window", created);
131
+ }
132
+
133
+ const windowId = created.stdout.trim();
134
+ if (!windowId) {
135
+ throw new Error("tmux created a window without returning its id.");
136
+ }
137
+ const stamped = await executor.exec(
138
+ TMUX_COMMAND,
139
+ ["set-option", "-w", "-t", windowId, SESSION_ID_OPTION, options.piSessionId],
140
+ { timeout: TMUX_TIMEOUT_MS },
141
+ );
142
+ if (stamped.code !== 0) {
143
+ await executor.exec(TMUX_COMMAND, ["kill-window", "-t", windowId], {
144
+ timeout: TMUX_TIMEOUT_MS,
145
+ });
146
+ throw tmuxError("stamp window", stamped);
147
+ }
148
+
149
+ return { windowId, name: options.name, piSessionId: options.piSessionId };
150
+ }
151
+
152
+ export async function killTmuxWindow(
153
+ executor: TmuxExecutor,
154
+ tmuxSession: string,
155
+ piSessionId: string,
156
+ ): Promise<boolean> {
157
+ const windows = await listTmuxWindows(executor, tmuxSession);
158
+ for (const window of windows.filter((candidate) => candidate.piSessionId === piSessionId)) {
159
+ await executor.exec(TMUX_COMMAND, ["kill-window", "-t", window.windowId], {
160
+ timeout: TMUX_TIMEOUT_MS,
161
+ });
162
+ }
163
+ return !(await listTmuxWindows(executor, tmuxSession)).some(
164
+ (window) => window.piSessionId === piSessionId,
165
+ );
166
+ }
167
+
168
+ export async function killTmuxSession(
169
+ executor: TmuxExecutor,
170
+ tmuxSession: string,
171
+ ): Promise<boolean> {
172
+ const result = await executor.exec(TMUX_COMMAND, ["kill-session", "-t", tmuxSession], {
173
+ timeout: TMUX_TIMEOUT_MS,
174
+ });
175
+ if (result.code !== 0 && !isMissingSession(result)) {
176
+ return false;
177
+ }
178
+ return !(await hasTmuxSession(executor, tmuxSession));
179
+ }
180
+
181
+ async function hasTmuxSession(executor: TmuxExecutor, tmuxSession: string): Promise<boolean> {
182
+ const result = await executor.exec(TMUX_COMMAND, ["has-session", "-t", tmuxSession], {
183
+ timeout: TMUX_TIMEOUT_MS,
184
+ });
185
+ return result.code === 0;
186
+ }
187
+
188
+ function parseWindow(line: string): TmuxWindow | undefined {
189
+ const [windowId, name, piSessionId] = line.split("\t");
190
+ if (!windowId || name === undefined || !piSessionId) {
191
+ return undefined;
192
+ }
193
+ return { windowId, name, piSessionId };
194
+ }
195
+
196
+ function isMissingSession(result: ExecResult): boolean {
197
+ return /can't find session|no server running|failed to connect to server|error connecting to .*no such file/i.test(
198
+ `${result.stderr}\n${result.stdout}`,
199
+ );
200
+ }
201
+
202
+ function tmuxError(action: string, result: ExecResult): Error {
203
+ const details = `${result.stderr || result.stdout}`.trim() || `exit code ${result.code}`;
204
+ return new Error(`Failed to ${action} with tmux: ${details}`);
205
+ }