Skip to content

Commit edb2289

Browse files
committed
Merge branch 'main' of github.com:PostHog/Array into feat/cancel-prompt
2 parents 268ca62 + 8eb718b commit edb2289

File tree

25 files changed

+457
-188
lines changed

25 files changed

+457
-188
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,42 @@ Clean up Docker containers:
126126
}
127127
}
128128
```
129+
130+
## Workspace Environment Variables
131+
132+
Array automatically sets environment variables in all workspace terminals and scripts. These are available in `init`, `start`, and `destroy` scripts, as well as any terminal sessions opened within a workspace.
133+
134+
| Variable | Description | Example |
135+
|----------|-------------|---------|
136+
| `ARRAY_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` |
137+
| `ARRAY_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.array/worktrees/repo/my-feature` |
138+
| `ARRAY_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` |
139+
| `ARRAY_DEFAULT_BRANCH` | Default branch detected from git | `main` |
140+
| `ARRAY_WORKSPACE_BRANCH` | Initial branch when workspace was created | `array/my-feature` |
141+
| `ARRAY_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` |
142+
| `ARRAY_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` |
143+
| `ARRAY_WORKSPACE_PORTS_START` | First port in the range | `50000` |
144+
| `ARRAY_WORKSPACE_PORTS_END` | Last port in the range | `50019` |
145+
146+
Note: `ARRAY_WORKSPACE_BRANCH` reflects the branch at workspace creation time. If you or the agent checks out a different branch, this variable will still show the original branch name.
147+
148+
### Port Allocation
149+
150+
Each workspace is assigned a unique range of 20 ports starting from port 50000. The allocation is deterministic based on the task ID, so the same workspace always receives the same ports across restarts.
151+
152+
### Usage Examples
153+
154+
Use ports in your start scripts:
155+
```json
156+
{
157+
"scripts": {
158+
"start": "npm run dev -- --port $ARRAY_WORKSPACE_PORTS_START"
159+
}
160+
}
161+
```
162+
163+
Reference the workspace path:
164+
```bash
165+
echo "Working in: $ARRAY_WORKSPACE_NAME"
166+
echo "Root repo: $ARRAY_ROOT_PATH"
167+
```

apps/array/src/main/lib/shellManager.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ function getDefaultShell(): string {
2121
return process.env.SHELL || "/bin/bash";
2222
}
2323

24-
function buildShellEnv(): Record<string, string> {
24+
function buildShellEnv(
25+
additionalEnv?: Record<string, string>,
26+
): Record<string, string> {
2527
const env = { ...process.env } as Record<string, string>;
2628

2729
if (os.platform() === "darwin" && !process.env.LC_ALL) {
@@ -38,6 +40,10 @@ function buildShellEnv(): Record<string, string> {
3840
env.COLORTERM = "truecolor";
3941
env.FORCE_COLOR = "3";
4042

43+
if (additionalEnv) {
44+
Object.assign(env, additionalEnv);
45+
}
46+
4147
return env;
4248
}
4349

@@ -46,13 +52,15 @@ export interface CreateSessionOptions {
4652
webContents: WebContents;
4753
cwd?: string;
4854
initialCommand?: string;
55+
additionalEnv?: Record<string, string>;
4956
}
5057

5158
class ShellManagerImpl {
5259
private sessions = new Map<string, ShellSession>();
5360

5461
createSession(options: CreateSessionOptions): ShellSession {
55-
const { sessionId, webContents, cwd, initialCommand } = options;
62+
const { sessionId, webContents, cwd, initialCommand, additionalEnv } =
63+
options;
5664

5765
const existing = this.sessions.get(sessionId);
5866
if (existing) {
@@ -74,7 +82,7 @@ class ShellManagerImpl {
7482
`Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`,
7583
);
7684

77-
const env = buildShellEnv();
85+
const env = buildShellEnv(additionalEnv);
7886
const ptyProcess = pty.spawn(shell, ["-l"], {
7987
name: "xterm-256color",
8088
cols: 80,

apps/array/src/main/preload.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
185185
apiHost: string;
186186
projectId: number;
187187
logUrl?: string;
188+
sdkSessionId?: string;
188189
}): Promise<{ sessionId: string; channel: string } | null> =>
189190
ipcRenderer.invoke("agent-reconnect", params),
190191
onAgentEvent: (
@@ -303,9 +304,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
303304
createVoidIpcListener("updates:ready", listener),
304305
installUpdate: (): Promise<{ installed: boolean }> =>
305306
ipcRenderer.invoke("updates:install"),
306-
// Shell API
307-
shellCreate: (sessionId: string, cwd?: string): Promise<void> =>
308-
ipcRenderer.invoke("shell:create", sessionId, cwd),
307+
shellCreate: (
308+
sessionId: string,
309+
cwd?: string,
310+
taskId?: string,
311+
): Promise<void> =>
312+
ipcRenderer.invoke("shell:create", sessionId, cwd, taskId),
309313
shellWrite: (sessionId: string, data: string): Promise<void> =>
310314
ipcRenderer.invoke("shell:write", sessionId, data),
311315
shellResize: (sessionId: string, cols: number, rows: number): Promise<void> =>

apps/array/src/main/services/contextMenu.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,30 @@ export const showTaskContextMenuService = createIpcService({
106106
worktreePath?: string,
107107
): Promise<TaskContextMenuResult> => {
108108
return new Promise((resolve) => {
109+
let pendingDialog = false;
110+
let resolved = false;
111+
112+
const safeResolve = (result: TaskContextMenuResult) => {
113+
if (!resolved) {
114+
resolved = true;
115+
resolve(result);
116+
}
117+
};
118+
109119
const template: MenuItemConstructorOptions[] = [
110120
{
111121
label: "Rename",
112-
click: () => resolve({ action: "rename" }),
122+
click: () => safeResolve({ action: "rename" }),
113123
},
114124
{
115125
label: "Duplicate",
116-
click: () => resolve({ action: "duplicate" }),
126+
click: () => safeResolve({ action: "duplicate" }),
117127
},
118128
{ type: "separator" },
119129
{
120130
label: "Delete",
121131
click: async () => {
132+
pendingDialog = true;
122133
const result = await dialog.showMessageBox({
123134
type: "question",
124135
title: "Delete Task",
@@ -132,9 +143,9 @@ export const showTaskContextMenuService = createIpcService({
132143
});
133144

134145
if (result.response === 1) {
135-
resolve({ action: "delete" });
146+
safeResolve({ action: "delete" });
136147
} else {
137-
resolve({ action: null });
148+
safeResolve({ action: null });
138149
}
139150
},
140151
},
@@ -145,12 +156,19 @@ export const showTaskContextMenuService = createIpcService({
145156
template.push({ type: "separator" });
146157
const externalAppsItems = await buildExternalAppsMenuItems(
147158
worktreePath,
148-
resolve,
159+
safeResolve,
149160
);
150161
template.push(...externalAppsItems);
151162
}
152163

153-
showContextMenu(template, { action: null }).then(resolve);
164+
const menu = Menu.buildFromTemplate(template);
165+
menu.popup({
166+
callback: () => {
167+
if (!pendingDialog) {
168+
safeResolve({ action: null });
169+
}
170+
},
171+
});
154172
};
155173

156174
setupMenu();
@@ -167,10 +185,21 @@ export const showFolderContextMenuService = createIpcService({
167185
folderPath?: string,
168186
): Promise<FolderContextMenuResult> => {
169187
return new Promise((resolve) => {
188+
let pendingDialog = false;
189+
let resolved = false;
190+
191+
const safeResolve = (result: FolderContextMenuResult) => {
192+
if (!resolved) {
193+
resolved = true;
194+
resolve(result);
195+
}
196+
};
197+
170198
const template: MenuItemConstructorOptions[] = [
171199
{
172200
label: "Remove folder",
173201
click: async () => {
202+
pendingDialog = true;
174203
const result = await dialog.showMessageBox({
175204
type: "question",
176205
title: "Remove Folder",
@@ -183,9 +212,9 @@ export const showFolderContextMenuService = createIpcService({
183212
});
184213

185214
if (result.response === 1) {
186-
resolve({ action: "remove" });
215+
safeResolve({ action: "remove" });
187216
} else {
188-
resolve({ action: null });
217+
safeResolve({ action: null });
189218
}
190219
},
191220
},
@@ -196,12 +225,19 @@ export const showFolderContextMenuService = createIpcService({
196225
template.push({ type: "separator" });
197226
const externalAppsItems = await buildExternalAppsMenuItems(
198227
folderPath,
199-
resolve,
228+
safeResolve,
200229
);
201230
template.push(...externalAppsItems);
202231
}
203232

204-
showContextMenu(template, { action: null }).then(resolve);
233+
const menu = Menu.buildFromTemplate(template);
234+
menu.popup({
235+
callback: () => {
236+
if (!pendingDialog) {
237+
safeResolve({ action: null });
238+
}
239+
},
240+
});
205241
};
206242

207243
setupMenu();

apps/array/src/main/services/git.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const getRemoteUrl = async (
7777
}
7878
};
7979

80-
const getCurrentBranch = async (
80+
export const getCurrentBranch = async (
8181
directoryPath: string,
8282
): Promise<string | undefined> => {
8383
try {
@@ -90,7 +90,9 @@ const getCurrentBranch = async (
9090
}
9191
};
9292

93-
const getDefaultBranch = async (directoryPath: string): Promise<string> => {
93+
export const getDefaultBranch = async (
94+
directoryPath: string,
95+
): Promise<string> => {
9496
try {
9597
// Try to get the default branch from origin
9698
const { stdout } = await execAsync(

apps/array/src/main/services/session-manager.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export interface SessionConfig {
122122
repoPath: string;
123123
credentials: PostHogCredentials;
124124
logUrl?: string; // For reconnection from S3
125+
sdkSessionId?: string; // SDK session ID for resuming Claude Code context
125126
}
126127

127128
export interface ManagedSession {
@@ -171,7 +172,8 @@ export class SessionManager {
171172
config: SessionConfig,
172173
isReconnect: boolean,
173174
): Promise<ManagedSession | null> {
174-
const { taskId, taskRunId, repoPath, credentials, logUrl } = config;
175+
const { taskId, taskRunId, repoPath, credentials, logUrl, sdkSessionId } =
176+
config;
175177

176178
const existing = this.sessions.get(taskRunId);
177179
if (existing) {
@@ -214,9 +216,12 @@ export class SessionManager {
214216
sessionId: taskRunId,
215217
cwd: repoPath,
216218
mcpServers: [],
217-
_meta: logUrl
218-
? { persistence: { taskId, runId: taskRunId, logUrl } }
219-
: undefined,
219+
_meta: {
220+
...(logUrl && {
221+
persistence: { taskId, runId: taskRunId, logUrl },
222+
}),
223+
...(sdkSessionId && { sdkSessionId }),
224+
},
220225
});
221226
} else {
222227
await connection.newSession({
@@ -452,6 +457,7 @@ interface AgentSessionParams {
452457
apiHost: string;
453458
projectId: number;
454459
logUrl?: string;
460+
sdkSessionId?: string;
455461
}
456462

457463
type SessionResponse = { sessionId: string; channel: string };
@@ -480,6 +486,7 @@ function toSessionConfig(params: AgentSessionParams): SessionConfig {
480486
projectId: params.projectId,
481487
},
482488
logUrl: params.logUrl,
489+
sdkSessionId: params.sdkSessionId,
483490
};
484491
}
485492

apps/array/src/main/services/shell.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
import { createIpcHandler } from "../lib/ipcHandler";
22
import { shellManager } from "../lib/shellManager";
3+
import { foldersStore } from "./store";
4+
import { buildWorkspaceEnv } from "./workspace/workspaceEnv";
35

46
const handle = createIpcHandler("shell");
57

68
export function registerShellIpc(): void {
7-
handle("shell:create", (event, sessionId: string, cwd?: string) => {
8-
shellManager.createSession({
9-
sessionId,
10-
webContents: event.sender,
11-
cwd,
12-
});
13-
});
9+
handle(
10+
"shell:create",
11+
async (event, sessionId: string, cwd?: string, taskId?: string) => {
12+
let additionalEnv: Record<string, string> | undefined;
13+
14+
if (taskId) {
15+
const associations = foldersStore.get("taskAssociations", []);
16+
const association = associations.find((a) => a.taskId === taskId);
17+
if (association && association.mode !== "cloud") {
18+
additionalEnv = await buildWorkspaceEnv({
19+
taskId,
20+
folderPath: association.folderPath,
21+
worktreePath: association.worktree?.worktreePath ?? null,
22+
worktreeName: association.worktree?.worktreeName ?? null,
23+
mode: association.mode,
24+
});
25+
}
26+
}
27+
28+
shellManager.createSession({
29+
sessionId,
30+
webContents: event.sender,
31+
cwd,
32+
additionalEnv,
33+
});
34+
},
35+
);
1436

1537
handle("shell:write", (_event, sessionId: string, data: string) => {
1638
shellManager.write(sessionId, data);

apps/array/src/main/services/workspace/configLoader.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ export async function loadConfig(
5959
return { config: null, source: null };
6060
}
6161

62-
// No config found
63-
log.debug(`No array.json config found for workspace ${worktreeName}`);
6462
return { config: null, source: null };
6563
}
6664

0 commit comments

Comments
 (0)