pi-sessions 0.8.0 → 0.10.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 (111) hide show
  1. package/README.md +74 -37
  2. package/dist/session-messaging/broker/process.js +234 -0
  3. package/dist/shared/session-broker/framing.js +58 -0
  4. package/dist/shared/session-broker/protocol.js +163 -0
  5. package/dist/shared/session-broker/socket-path.js +19 -0
  6. package/dist/shared/typebox.js +36 -0
  7. package/extensions/pi-sessions.ts +125 -0
  8. package/extensions/session-ask/agent.ts +34 -14
  9. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  10. package/extensions/session-ask/navigate.ts +3 -1
  11. package/extensions/session-ask/presenter.ts +23 -0
  12. package/extensions/session-ask/renderer.ts +63 -0
  13. package/extensions/session-ask/tool-contract.ts +27 -0
  14. package/extensions/session-ask/view-model.ts +18 -0
  15. package/extensions/session-auto-title/generate.ts +18 -18
  16. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
  17. package/extensions/session-auto-title/model.ts +25 -17
  18. package/extensions/session-auto-title/retitle.ts +17 -18
  19. package/extensions/session-auto-title/wizard.ts +12 -11
  20. package/extensions/session-handoff/board-view-model.ts +179 -0
  21. package/extensions/session-handoff/board.ts +663 -0
  22. package/extensions/session-handoff/bootstrap.ts +172 -0
  23. package/extensions/session-handoff/extract.ts +129 -132
  24. package/extensions/session-handoff/install.ts +225 -0
  25. package/extensions/session-handoff/kickoff.ts +92 -0
  26. package/extensions/session-handoff/launch/backend.ts +16 -0
  27. package/extensions/session-handoff/launch/deferred.ts +20 -0
  28. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  29. package/extensions/session-handoff/launch/resolution.ts +57 -0
  30. package/extensions/session-handoff/launch/shell.ts +7 -0
  31. package/extensions/session-handoff/launch/tmux.ts +31 -0
  32. package/extensions/session-handoff/launch-options.ts +61 -0
  33. package/extensions/session-handoff/launch-target.ts +91 -0
  34. package/extensions/session-handoff/metadata.ts +194 -56
  35. package/extensions/session-handoff/model.ts +58 -0
  36. package/extensions/session-handoff/query.ts +0 -1
  37. package/extensions/session-handoff/receipt.ts +96 -0
  38. package/extensions/session-handoff/review.ts +2 -28
  39. package/extensions/session-handoff/spawn.ts +76 -181
  40. package/extensions/session-handoff/strong-modal.ts +13 -0
  41. package/extensions/session-handoff/tool-contract.ts +12 -0
  42. package/extensions/session-handoff/tool-presenter.ts +35 -0
  43. package/extensions/session-handoff/tool-renderer.ts +66 -0
  44. package/extensions/session-handoff/tool-schema.ts +54 -0
  45. package/extensions/session-handoff/tool-view-model.ts +49 -0
  46. package/extensions/session-handoff/tool.ts +206 -0
  47. package/extensions/session-handoff/ui.ts +66 -0
  48. package/extensions/session-hooks/install.ts +75 -0
  49. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  50. package/extensions/session-messaging/broker/process.ts +21 -28
  51. package/extensions/session-messaging/broker/spawn.ts +12 -7
  52. package/extensions/session-messaging/install.ts +78 -0
  53. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  54. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  55. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  56. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  57. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  58. package/extensions/session-messaging/pi/renderer.ts +19 -12
  59. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  60. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  61. package/extensions/session-messaging/pi/service.ts +262 -81
  62. package/extensions/session-messaging/pi/tools.ts +184 -40
  63. package/extensions/session-search/extract.ts +60 -14
  64. package/extensions/session-search/hooks.ts +10 -13
  65. package/extensions/session-search/install.ts +373 -0
  66. package/extensions/session-search/renderer.ts +195 -0
  67. package/extensions/session-search/tool-contract.ts +51 -0
  68. package/extensions/shared/composition.ts +20 -0
  69. package/extensions/shared/errors.ts +3 -0
  70. package/extensions/shared/model-resolution.ts +73 -0
  71. package/extensions/shared/model-runtime.ts +33 -0
  72. package/extensions/shared/model.ts +7 -10
  73. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  74. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  75. package/extensions/shared/rendering/theme.ts +5 -0
  76. package/extensions/shared/session-broker/client.ts +30 -29
  77. package/extensions/shared/session-broker/protocol.ts +128 -10
  78. package/extensions/shared/session-index/common.ts +5 -5
  79. package/extensions/shared/session-index/lineage.ts +0 -3
  80. package/extensions/shared/session-index/schema.ts +0 -1
  81. package/extensions/shared/session-index/scoring.ts +1 -2
  82. package/extensions/shared/session-index/search.ts +11 -4
  83. package/extensions/shared/session-index/store.ts +4 -9
  84. package/extensions/shared/settings.ts +83 -40
  85. package/extensions/shared/text.ts +5 -0
  86. package/extensions/shared/thinking-levels.ts +22 -0
  87. package/extensions/shared/tmux.ts +205 -0
  88. package/extensions/subagents/cancel.ts +111 -0
  89. package/extensions/subagents/classify.ts +58 -0
  90. package/extensions/subagents/install.ts +229 -0
  91. package/extensions/subagents/launch-target.ts +75 -0
  92. package/extensions/subagents/ledger.ts +217 -0
  93. package/extensions/subagents/reconcile.ts +386 -0
  94. package/extensions/subagents/report-message-presenter.ts +36 -0
  95. package/extensions/subagents/report-message-renderer.ts +21 -0
  96. package/extensions/subagents/report-message-view-model.ts +29 -0
  97. package/extensions/subagents/report.ts +159 -0
  98. package/extensions/subagents/roster.ts +262 -0
  99. package/extensions/subagents/settle.ts +182 -0
  100. package/extensions/subagents/wake.ts +173 -0
  101. package/images/handoff-board-subagents.png +0 -0
  102. package/images/handoff-board-user-sessions.png +0 -0
  103. package/images/handoff-subagent-report.png +0 -0
  104. package/images/session-handoff-tool.png +0 -0
  105. package/package.json +16 -18
  106. package/extensions/session-handoff.ts +0 -741
  107. package/extensions/session-hooks.ts +0 -75
  108. package/extensions/session-messaging/pi/message-view.ts +0 -131
  109. package/extensions/session-messaging.ts +0 -30
  110. package/extensions/session-search.ts +0 -419
  111. 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,41 @@ 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
+ model: Type.Optional(Type.String()),
31
+ thinkingLevel: Type.Optional(Type.String()),
32
+ deferred: Type.Optional(
33
+ Type.Object({
34
+ copyToClipboard: Type.Optional(Type.Boolean()),
35
+ }),
36
+ ),
37
+ }),
38
+ ),
39
+ search: Type.Optional(
40
+ Type.Object({
41
+ enable: Type.Optional(Type.Boolean()),
16
42
  }),
17
43
  ),
18
44
  index: Type.Optional(
@@ -22,6 +48,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
22
48
  ),
23
49
  autoTitle: Type.Optional(
24
50
  Type.Object({
51
+ enable: Type.Optional(Type.Boolean()),
25
52
  refreshTurns: Type.Optional(Type.Integer({ minimum: 1 })),
26
53
  timeoutSecs: Type.Optional(Type.Integer({ minimum: 1 })),
27
54
  model: Type.Optional(Type.String()),
@@ -31,29 +58,24 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
31
58
  ),
32
59
  ask: Type.Optional(
33
60
  Type.Object({
61
+ enable: Type.Optional(Type.Boolean()),
34
62
  model: Type.Optional(Type.String()),
35
63
  thinkingLevel: Type.Optional(Type.String()),
36
64
  persistRuns: Type.Optional(Type.Boolean()),
37
65
  }),
38
66
  ),
67
+ hooks: Type.Optional(
68
+ Type.Object({
69
+ enable: Type.Optional(Type.Boolean()),
70
+ }),
71
+ ),
39
72
  });
40
73
  const ROOT_SETTINGS_SCHEMA = Type.Object({
41
74
  sessions: Type.Optional(SESSION_FILE_SETTINGS_SCHEMA),
42
75
  });
43
76
 
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
77
  export interface AgentModelSettings {
56
- model?: ModelReference | undefined;
78
+ model?: string | undefined;
57
79
  thinkingLevel?: ThinkingLevel | undefined;
58
80
  }
59
81
 
@@ -67,10 +89,30 @@ export interface AskSettings extends AgentModelSettings {
67
89
  persistRuns: boolean;
68
90
  }
69
91
 
92
+ export interface HandoffSettings extends AgentModelSettings {
93
+ pickerShortcut: KeyId;
94
+ persistRuns: boolean;
95
+ deferred: {
96
+ copyToClipboard: boolean;
97
+ };
98
+ }
99
+
100
+ export interface FeatureToggles {
101
+ messaging: boolean;
102
+ subagents: boolean;
103
+ handoff: boolean;
104
+ search: boolean;
105
+ ask: boolean;
106
+ autoTitle: boolean;
107
+ hooks: boolean;
108
+ }
109
+
70
110
  export interface SessionSettings {
71
- handoff: {
72
- pickerShortcut: KeyId;
111
+ features: FeatureToggles;
112
+ subagents: {
113
+ maxDepth: number;
73
114
  };
115
+ handoff: HandoffSettings;
74
116
  index: {
75
117
  path: string;
76
118
  };
@@ -92,6 +134,10 @@ export function getDefaultSessionAskRunsDir(): string {
92
134
  return path.join(getDefaultIndexDir(), "session-ask");
93
135
  }
94
136
 
137
+ export function getDefaultHandoffRunsDir(): string {
138
+ return path.join(getDefaultIndexDir(), "session-handoff");
139
+ }
140
+
95
141
  function expandHome(rawPath: string): string {
96
142
  if (rawPath === "~") {
97
143
  return os.homedir();
@@ -127,33 +173,9 @@ function normalizePickerShortcut(value: string | undefined): KeyId {
127
173
  return (trimmed ? trimmed : "alt+o") as KeyId;
128
174
  }
129
175
 
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
176
  function parseThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
145
177
  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
- }
178
+ return isThinkingLevel(trimmed) ? trimmed : undefined;
157
179
  }
158
180
 
159
181
  function resolveAgentModelSettings(
@@ -164,7 +186,7 @@ function resolveAgentModelSettings(
164
186
  }
165
187
  | undefined,
166
188
  ): AgentModelSettings {
167
- const model = parseModelReference(value?.model);
189
+ const model = value?.model?.trim() || undefined;
168
190
  const thinkingLevel = parseThinkingLevel(value?.thinkingLevel);
169
191
  return {
170
192
  ...(model ? { model } : {}),
@@ -183,12 +205,33 @@ function loadSessionFileSettings(): SessionFileSettings {
183
205
  return parsed.sessions ?? {};
184
206
  }
185
207
 
208
+ function resolveFeatureToggles(value: SessionFileSettings): FeatureToggles {
209
+ return {
210
+ messaging: value.messaging?.enable ?? true,
211
+ subagents: value.subagents?.enable ?? true,
212
+ handoff: value.handoff?.enable ?? true,
213
+ search: value.search?.enable ?? true,
214
+ ask: value.ask?.enable ?? true,
215
+ autoTitle: value.autoTitle?.enable ?? true,
216
+ hooks: value.hooks?.enable ?? true,
217
+ };
218
+ }
219
+
186
220
  function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSettings {
187
221
  const indexDir = normalizeIndexDir(fileSettings.index?.dir);
188
222
 
189
223
  return {
224
+ features: resolveFeatureToggles(fileSettings),
225
+ subagents: {
226
+ maxDepth: fileSettings.subagents?.maxDepth ?? 2,
227
+ },
190
228
  handoff: {
229
+ ...resolveAgentModelSettings(fileSettings.handoff),
191
230
  pickerShortcut: normalizePickerShortcut(fileSettings.handoff?.pickerShortcut),
231
+ persistRuns: fileSettings.handoff?.persistRuns ?? false,
232
+ deferred: {
233
+ copyToClipboard: fileSettings.handoff?.deferred?.copyToClipboard ?? true,
234
+ },
192
235
  },
193
236
  index: {
194
237
  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
+ }