@catdaemon/pi-cmux 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlton Downie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @catdaemon/pi-cmux
2
+
3
+ cmux status and notification integration for Pi.
4
+
5
+ The extension updates cmux status when the main Pi agent starts/finishes work and sends a cmux notification when a turn completes. It also exports helper functions for other Pi extensions that need cmux notifications.
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from './src/cmuxIntegration.ts'
2
+ export * from './src/cmux.ts'
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@catdaemon/pi-cmux",
3
+ "version": "0.1.0",
4
+ "description": "cmux status and notification integration for Pi.",
5
+ "type": "module",
6
+ "keywords": ["pi-package", "pi", "cmux", "notifications"],
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Catdaemon/pi-extensions",
11
+ "directory": "packages/cmux"
12
+ },
13
+ "homepage": "https://github.com/Catdaemon/pi-extensions/tree/main/packages/cmux#readme",
14
+ "bugs": { "url": "https://github.com/Catdaemon/pi-extensions/issues" },
15
+ "pi": { "extensions": ["./index.ts"] },
16
+ "files": ["index.ts", "src", "README.md", "LICENSE", "package.json", "tsconfig.json"],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "node --import tsx --test src/tests/*.test.ts",
20
+ "pack:dry-run": "npm pack --dry-run --ignore-scripts",
21
+ "prepack": "npm run typecheck && npm test"
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*"
25
+ }
26
+ }
package/src/cmux.ts ADDED
@@ -0,0 +1,147 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+
3
+ type PiExecResult = {
4
+ stdout?: string
5
+ stderr?: string
6
+ code?: number
7
+ killed?: boolean
8
+ }
9
+
10
+ export type CmuxNotifyOptions = {
11
+ title: string
12
+ subtitle?: string
13
+ body?: string
14
+ workspace?: string
15
+ surface?: string
16
+ signal?: AbortSignal
17
+ }
18
+
19
+ const CMUX_TIMEOUT_MS = 3000
20
+
21
+ function readString(value: unknown): string | undefined {
22
+ return typeof value === 'string' ? value : undefined
23
+ }
24
+
25
+ function readNumber(value: unknown): number | undefined {
26
+ return typeof value === 'number' ? value : undefined
27
+ }
28
+
29
+ function readBoolean(value: unknown): boolean | undefined {
30
+ return typeof value === 'boolean' ? value : undefined
31
+ }
32
+
33
+ function normalizeExecResult(value: unknown): PiExecResult {
34
+ if (!value || typeof value !== 'object') return {}
35
+ return {
36
+ stdout: readString('stdout' in value ? value.stdout : undefined),
37
+ stderr: readString('stderr' in value ? value.stderr : undefined),
38
+ code: readNumber('code' in value ? value.code : undefined),
39
+ killed: readBoolean('killed' in value ? value.killed : undefined),
40
+ }
41
+ }
42
+
43
+ export function isCmuxEnvironment(env: NodeJS.ProcessEnv = process.env) {
44
+ return Boolean(env.CMUX_WORKSPACE_ID && env.CMUX_SURFACE_ID)
45
+ }
46
+
47
+ export async function runCmux(
48
+ pi: ExtensionAPI,
49
+ args: string[],
50
+ options: { signal?: AbortSignal; timeout?: number; requireCmuxEnv?: boolean } = {}
51
+ ) {
52
+ if (options.requireCmuxEnv !== false && !isCmuxEnvironment()) {
53
+ return { ok: false, skipped: true, reason: 'not in cmux', stdout: '', stderr: '' }
54
+ }
55
+
56
+ try {
57
+ const result = normalizeExecResult(await pi.exec('cmux', args, {
58
+ signal: options.signal,
59
+ timeout: options.timeout ?? CMUX_TIMEOUT_MS,
60
+ }))
61
+
62
+ const stdout = result.stdout ?? ''
63
+ const stderr = result.stderr ?? ''
64
+ const code = result.code ?? 0
65
+
66
+ return {
67
+ ok: code === 0 && !result.killed,
68
+ skipped: false,
69
+ code,
70
+ killed: Boolean(result.killed),
71
+ stdout,
72
+ stderr,
73
+ reason: code === 0 && !result.killed ? undefined : stderr || stdout || `cmux exited with code ${code}`,
74
+ }
75
+ } catch (error) {
76
+ return {
77
+ ok: false,
78
+ skipped: false,
79
+ code: undefined,
80
+ killed: false,
81
+ stdout: '',
82
+ stderr: error instanceof Error ? error.message : String(error),
83
+ reason: error instanceof Error ? error.message : String(error),
84
+ }
85
+ }
86
+ }
87
+
88
+ export function getCmuxWorkspace(env: NodeJS.ProcessEnv = process.env) {
89
+ return env.CMUX_WORKSPACE_ID
90
+ }
91
+
92
+ export function getCmuxSurface(env: NodeJS.ProcessEnv = process.env) {
93
+ return env.CMUX_SURFACE_ID
94
+ }
95
+
96
+ export async function notifyCmux(pi: ExtensionAPI, options: CmuxNotifyOptions) {
97
+ const args = ['notify', '--title', options.title]
98
+
99
+ if (options.subtitle) args.push('--subtitle', options.subtitle)
100
+ if (options.body) args.push('--body', options.body)
101
+ if (options.workspace) args.push('--workspace', options.workspace)
102
+ if (options.surface) args.push('--surface', options.surface)
103
+
104
+ return runCmux(pi, args, { signal: options.signal })
105
+ }
106
+
107
+ export async function notifyCmuxNeedsFeedback(
108
+ pi: ExtensionAPI,
109
+ body: string,
110
+ options: { title?: string; subtitle?: string; signal?: AbortSignal } = {}
111
+ ) {
112
+ return notifyCmux(pi, {
113
+ title: options.title ?? 'Pi needs feedback',
114
+ subtitle: options.subtitle ?? 'Action required',
115
+ body,
116
+ signal: options.signal,
117
+ })
118
+ }
119
+
120
+ export async function notifyCmuxDone(
121
+ pi: ExtensionAPI,
122
+ body: string,
123
+ options: { title?: string; subtitle?: string; signal?: AbortSignal } = {}
124
+ ) {
125
+ return notifyCmux(pi, {
126
+ title: options.title ?? 'Pi',
127
+ subtitle: options.subtitle ?? 'Done',
128
+ body,
129
+ signal: options.signal,
130
+ })
131
+ }
132
+
133
+ export async function setCmuxStatus(
134
+ pi: ExtensionAPI,
135
+ key: string,
136
+ value: string,
137
+ options: { icon?: string; color?: string; signal?: AbortSignal } = {}
138
+ ) {
139
+ const args = ['set-status', key, value]
140
+ if (options.icon) args.push('--icon', options.icon)
141
+ if (options.color) args.push('--color', options.color)
142
+ return runCmux(pi, args, { signal: options.signal })
143
+ }
144
+
145
+ export async function clearCmuxStatus(pi: ExtensionAPI, key: string, options: { signal?: AbortSignal } = {}) {
146
+ return runCmux(pi, ['clear-status', key], { signal: options.signal })
147
+ }
@@ -0,0 +1,123 @@
1
+ import { join } from 'node:path'
2
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
3
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
4
+ import {
5
+ clearCmuxStatus,
6
+ getCmuxSurface,
7
+ getCmuxWorkspace,
8
+ isCmuxEnvironment,
9
+ notifyCmuxDone,
10
+ setCmuxStatus,
11
+ } from './cmux.ts'
12
+
13
+ const DEFAULT_STATUS_KEY = 'pi-agent'
14
+ const SUBAGENT_SESSION_DIR = join(getAgentDir(), 'subagents', 'sessions')
15
+
16
+ type CmuxIntegrationConfig = {
17
+ statusKey: string
18
+ notifyDone: boolean
19
+ includePromptPreviewInStatus: boolean
20
+ includeSubagents: boolean
21
+ }
22
+
23
+ function readConfig(env: NodeJS.ProcessEnv = process.env): CmuxIntegrationConfig {
24
+ return {
25
+ statusKey: env.PI_CMUX_STATUS_KEY || DEFAULT_STATUS_KEY,
26
+ notifyDone: env.PI_CMUX_NOTIFY_DONE !== '0',
27
+ includePromptPreviewInStatus: env.PI_CMUX_STATUS_PREVIEW === '1',
28
+ includeSubagents: env.PI_CMUX_INCLUDE_SUBAGENTS === '1',
29
+ }
30
+ }
31
+
32
+ function truncatePreview(text: string, limit = 140) {
33
+ const trimmed = text.replace(/\s+/g, ' ').trim()
34
+ if (trimmed.length <= limit) return trimmed
35
+ return `${trimmed.slice(0, limit - 3)}...`
36
+ }
37
+
38
+ function isSubagentSession(ctx: ExtensionContext) {
39
+ const file = ctx.sessionManager.getSessionFile()
40
+ return Boolean(file && file.startsWith(SUBAGENT_SESSION_DIR))
41
+ }
42
+
43
+ function isSubagentPrompt(prompt: string) {
44
+ return prompt.trimStart().startsWith('You are a subagent helping another pi agent.')
45
+ }
46
+
47
+ function shouldIgnoreTurn(ctx: ExtensionContext, prompt: string, config: CmuxIntegrationConfig) {
48
+ return !config.includeSubagents && (isSubagentSession(ctx) || isSubagentPrompt(prompt))
49
+ }
50
+
51
+ function sessionKey(ctx: ExtensionContext) {
52
+ return ctx.sessionManager.getSessionFile() ?? ctx.cwd
53
+ }
54
+
55
+ export default function cmuxIntegration(pi: ExtensionAPI) {
56
+ const activePrompts = new Map<string, string>()
57
+ const activeStarts = new Map<string, number>()
58
+ const config = readConfig()
59
+
60
+ pi.on('before_agent_start', async (event, ctx) => {
61
+ const key = sessionKey(ctx)
62
+ if (!isCmuxEnvironment() || shouldIgnoreTurn(ctx, event.prompt, config)) {
63
+ activePrompts.delete(key)
64
+ activeStarts.delete(key)
65
+ return undefined
66
+ }
67
+
68
+ const preview = truncatePreview(event.prompt)
69
+ activePrompts.set(key, preview)
70
+ activeStarts.set(key, Date.now())
71
+ const status = config.includePromptPreviewInStatus ? `Running: ${truncatePreview(event.prompt, 48)}` : 'Running'
72
+ void setCmuxStatus(pi, config.statusKey, status, { icon: 'sparkles', color: '#3b82f6', signal: ctx.signal })
73
+ return undefined
74
+ })
75
+
76
+ pi.on('agent_end', async (_event, ctx) => {
77
+ const key = sessionKey(ctx)
78
+ if (!isCmuxEnvironment() || (!config.includeSubagents && isSubagentSession(ctx))) {
79
+ activePrompts.delete(key)
80
+ activeStarts.delete(key)
81
+ return
82
+ }
83
+
84
+ const prompt = activePrompts.get(key)
85
+ const startedAt = activeStarts.get(key)
86
+ activePrompts.delete(key)
87
+ activeStarts.delete(key)
88
+
89
+ const elapsed = startedAt ? Math.max(1, Math.round((Date.now() - startedAt) / 1000)) : undefined
90
+ const idleText = elapsed ? `Idle (${elapsed}s)` : 'Idle'
91
+ void setCmuxStatus(pi, config.statusKey, idleText, { icon: 'check', color: '#34c759', signal: ctx.signal })
92
+
93
+ if (config.notifyDone) {
94
+ const body = prompt ? `Finished: ${prompt}` : 'Agent finished and is ready for your next message.'
95
+ void notifyCmuxDone(pi, elapsed ? `${body}\nElapsed: ${elapsed}s` : body, {
96
+ title: 'Pi',
97
+ subtitle: 'Done',
98
+ signal: ctx.signal,
99
+ })
100
+ }
101
+ })
102
+
103
+ pi.on('session_shutdown', async () => {
104
+ if (!isCmuxEnvironment()) return
105
+ void clearCmuxStatus(pi, config.statusKey)
106
+ })
107
+
108
+ pi.registerCommand('cmux-status', {
109
+ description: 'Show cmux integration status and environment wiring.',
110
+ handler: async (_args, ctx) => {
111
+ const message = [
112
+ `cmux: ${isCmuxEnvironment() ? 'enabled' : 'not detected'}`,
113
+ `workspace: ${getCmuxWorkspace() ?? '(none)'}`,
114
+ `surface: ${getCmuxSurface() ?? '(none)'}`,
115
+ `status key: ${config.statusKey}`,
116
+ `done notifications: ${config.notifyDone ? 'on' : 'off'}`,
117
+ `subagent turns: ${config.includeSubagents ? 'included' : 'ignored'}`,
118
+ ].join('\n')
119
+ if (ctx.hasUI) ctx.ui.notify(message, 'info')
120
+ else console.log(message)
121
+ },
122
+ })
123
+ }
@@ -0,0 +1,15 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import cmuxIntegration, { isCmuxEnvironment } from '../../index.ts'
4
+
5
+ describe('pi-cmux package', () => {
6
+ it('exports a Pi extension factory', () => {
7
+ assert.equal(typeof cmuxIntegration, 'function')
8
+ })
9
+
10
+ it('detects cmux environment only with workspace and surface ids', () => {
11
+ assert.equal(isCmuxEnvironment({}), false)
12
+ assert.equal(isCmuxEnvironment({ CMUX_WORKSPACE_ID: 'w' }), false)
13
+ assert.equal(isCmuxEnvironment({ CMUX_WORKSPACE_ID: 'w', CMUX_SURFACE_ID: 's' }), true)
14
+ })
15
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"],
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["index.ts", "src/**/*.ts"]
14
+ }