docs/superpowers/plans/2026-07-01-channel-p0-identity-task-lifecycle.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add channel identity/memory-scope metadata and a base task lifecycle hook for channel-resident agents.
Architecture: Keep the behavior inside @qwen-code/channel-base. Add typed metadata to types.ts, derive defaults in ChannelBase, inject a first-session boundary note alongside existing instructions, and emit lifecycle events through a protected no-op hook that adapters can override.
Tech Stack: TypeScript, Vitest, existing ChannelBase / ChannelConfig / ChannelAgentBridge infrastructure.
packages/channels/base/src/types.ts: add identity, memory-scope, runtime metadata, and lifecycle event types.packages/channels/base/src/ChannelBase.ts: derive metadata, inject prompt boundary, expose status metadata, emit lifecycle events.packages/channels/base/src/ChannelBase.test.ts: add focused tests using the existing TestChannel fixture.Files:
Modify: packages/channels/base/src/types.ts
Test: packages/channels/base/src/ChannelBase.test.ts
Step 1: Write failing type-driven tests in ChannelBase.test.ts
Add taskEvents to TestChannel and two tests near the existing instruction tests:
import type {
ChannelConfig,
ChannelTaskLifecycleEvent,
Envelope,
} from './types.js';
class TestChannel extends ChannelBase {
taskEvents: ChannelTaskLifecycleEvent[] = [];
protected override onTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
this.taskEvents.push(event);
}
}
it('derives default channel identity and memory metadata for task lifecycle events', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ messageId: 'm-1' }));
expect(ch.taskEvents[0]).toMatchObject({
type: 'started',
channelName: 'test-chan',
chatId: 'chat1',
sessionId: 's-1',
messageId: 'm-1',
identity: {
id: 'channel:test-chan',
displayName: 'test-chan',
},
memoryScope: {
namespace: 'channel:test-chan',
mode: 'metadata-only',
},
});
});
it('uses configured channel identity and memory namespace in lifecycle metadata', async () => {
const ch = createChannel({
identity: {
id: 'ops-agent',
displayName: 'Ops Agent',
description: 'Coordinates repository operations.',
},
memoryScope: {
namespace: 'qwen-tag:ops',
mode: 'metadata-only',
},
});
await ch.handleInbound(envelope());
expect(ch.taskEvents[0]).toMatchObject({
identity: {
id: 'ops-agent',
displayName: 'Ops Agent',
description: 'Coordinates repository operations.',
},
memoryScope: {
namespace: 'qwen-tag:ops',
mode: 'metadata-only',
},
});
});
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: fails because ChannelTaskLifecycleEvent, identity, memoryScope, and onTaskLifecycle do not exist.
types.tsInsert after DispatchMode:
export interface ChannelIdentityConfig {
id?: string;
displayName?: string;
description?: string;
}
export interface ChannelRuntimeIdentity {
id: string;
displayName: string;
description?: string;
}
export type ChannelMemoryScopeMode = 'metadata-only';
export interface ChannelMemoryScopeConfig {
namespace?: string;
mode?: ChannelMemoryScopeMode;
}
export interface ChannelRuntimeMemoryScope {
namespace: string;
mode: ChannelMemoryScopeMode;
}
Add to ChannelConfig after instructions?: string;:
identity?: ChannelIdentityConfig;
memoryScope?: ChannelMemoryScopeConfig;
Import ToolCallEvent at the top:
import type { ToolCallEvent } from './ChannelAgentBridge.js';
Add before ChannelPlugin:
interface ChannelTaskLifecycleBase {
channelName: string;
chatId: string;
sessionId: string;
messageId?: string;
identity: ChannelRuntimeIdentity;
memoryScope: ChannelRuntimeMemoryScope;
}
export type ChannelTaskLifecycleEvent =
| (ChannelTaskLifecycleBase & { type: 'started' })
| (ChannelTaskLifecycleBase & { type: 'text_chunk'; chunk: string })
| (Omit<ChannelTaskLifecycleBase, 'messageId'> & {
type: 'tool_call';
toolCall: ToolCallEvent;
})
| (ChannelTaskLifecycleBase & {
type: 'cancelled';
reason: 'cancel_command' | 'clear' | 'steer';
})
| (ChannelTaskLifecycleBase & { type: 'completed' })
| (ChannelTaskLifecycleBase & { type: 'failed'; error: string });
ChannelBase.tsUpdate imports:
import type {
ChannelConfig,
ChannelRuntimeIdentity,
ChannelRuntimeMemoryScope,
ChannelTaskLifecycleEvent,
DispatchMode,
Envelope,
} from './types.js';
Add private fields after protected name: string;:
private readonly identity: ChannelRuntimeIdentity;
private readonly memoryScope: ChannelRuntimeMemoryScope;
Set them in the constructor after this.proxy = options?.proxy;:
this.identity = this.resolveIdentity(name, config);
this.memoryScope = this.resolveMemoryScope(name, config);
Add methods near other protected hooks:
protected onTaskLifecycle(_event: ChannelTaskLifecycleEvent): void {}
private emitTaskLifecycle(event: ChannelTaskLifecycleEvent): void {
try {
this.onTaskLifecycle(event);
} catch (err) {
process.stderr.write(
`[${this.name}] onTaskLifecycle threw for ${event.type} session ${event.sessionId}: ${
err instanceof Error ? err.message : err
}\n`,
);
}
}
private resolveIdentity(
name: string,
config: ChannelConfig,
): ChannelRuntimeIdentity {
return {
id: config.identity?.id || `channel:${name}`,
displayName: config.identity?.displayName || name,
...(config.identity?.description
? { description: config.identity.description }
: {}),
};
}
private resolveMemoryScope(
name: string,
config: ChannelConfig,
): ChannelRuntimeMemoryScope {
return {
namespace: config.memoryScope?.namespace || `channel:${name}`,
mode: 'metadata-only',
};
}
Emit started after this.activePrompts.set(sessionId, promptState);:
this.emitTaskLifecycle({
type: 'started',
channelName: this.name,
chatId: envelope.chatId,
sessionId,
messageId: envelope.messageId,
identity: this.identity,
memoryScope: this.memoryScope,
});
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: both new metadata tests pass; existing tests still pass.
Files:
Modify: packages/channels/base/src/ChannelBase.ts
Test: packages/channels/base/src/ChannelBase.test.ts
Step 1: Write failing prompt and status tests
Add tests near existing instruction and command tests:
it('prepends channel boundary metadata before custom instructions once per session', async () => {
const ch = createChannel({
instructions: 'Be concise.',
identity: {
id: 'ops-agent',
displayName: 'Ops Agent',
description: 'Coordinates repository operations.',
},
memoryScope: {
namespace: 'qwen-tag:ops',
mode: 'metadata-only',
},
});
await ch.handleInbound(envelope({ text: 'first' }));
await ch.handleInbound(envelope({ text: 'second' }));
const prompt = vi.mocked(bridge.prompt).mock.calls[0]![1];
expect(prompt).toContain('Channel identity:');
expect(prompt).toContain('- id: ops-agent');
expect(prompt).toContain('- display name: Ops Agent');
expect(prompt).toContain(
'- description: Coordinates repository operations.',
);
expect(prompt).toContain('Memory scope:');
expect(prompt).toContain('- namespace: qwen-tag:ops');
expect(prompt).toContain('- mode: metadata-only');
expect(prompt).toContain('- storage isolation: not enforced by this version.');
expect(prompt.indexOf('Channel identity:')).toBeLessThan(
prompt.indexOf('Be concise.'),
);
const secondPrompt = vi.mocked(bridge.prompt).mock.calls[1]![1];
expect(secondPrompt).not.toContain('Channel identity:');
});
it('/who and /status include channel identity and memory metadata', async () => {
const ch = createChannel({
identity: { id: 'ops-agent', displayName: 'Ops Agent' },
memoryScope: { namespace: 'qwen-tag:ops', mode: 'metadata-only' },
});
await ch.handleInbound(envelope({ text: '/who' }));
await ch.handleInbound(envelope({ text: '/status' }));
expect(ch.sent[0]!.text).toContain('Identity: Ops Agent');
expect(ch.sent[0]!.text).toContain('Memory: qwen-tag:ops');
expect(ch.sent[1]!.text).toContain('Identity: ops-agent');
expect(ch.sent[1]!.text).toContain('Memory: metadata-only');
});
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: fails because prompt boundary and status lines are not implemented.
ChannelBase.tsAdd private method near metadata resolvers:
private shouldPrependChannelBoundaryPrompt(): boolean {
return Boolean(
this.config.instructions ||
this.config.identity ||
this.config.memoryScope,
);
}
private channelBoundaryPrompt(): string {
const identityLines = [
'Channel identity:',
`- id: ${this.identity.id}`,
`- display name: ${this.identity.displayName}`,
...(this.identity.description
? [`- description: ${this.identity.description}`]
: []),
];
const memoryLines = [
'Memory scope:',
`- namespace: ${this.memoryScope.namespace}`,
`- mode: ${this.memoryScope.mode}`,
'- storage isolation: not enforced by this version.',
];
return [...identityLines, '', ...memoryLines].join('\n');
}
Replace the existing instruction block:
if (this.config.instructions && !this.instructedSessions.has(sessionId)) {
promptText = `${this.config.instructions}\n\n${promptText}`;
this.instructedSessions.add(sessionId);
}
with:
if (
this.shouldPrependChannelBoundaryPrompt() &&
!this.instructedSessions.has(sessionId)
) {
const prefix = this.config.instructions
? `${this.channelBoundaryPrompt()}\n\n${this.config.instructions}`
: this.channelBoundaryPrompt();
promptText = `${prefix}\n\n${promptText}`;
this.instructedSessions.add(sessionId);
}
In /who lines, after Channel: ${this.name}, add:
`Identity: ${this.identity.displayName}`,
`Memory: ${this.memoryScope.namespace}`,
In /status lines, after Channel: ${this.name}, add:
`Identity: ${this.identity.id}`,
`Memory: ${this.memoryScope.mode}`,
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: prompt boundary and status tests pass; existing instruction tests are updated if they expected custom instructions to be the only prefix.
Files:
Modify: packages/channels/base/src/ChannelBase.ts
Test: packages/channels/base/src/ChannelBase.test.ts
Step 1: Write failing lifecycle tests
Add tests near existing streaming/cancel/dispatch tests:
it('emits task lifecycle for chunks, tool calls, and completion', async () => {
vi.mocked(bridge.prompt).mockImplementation(async () => {
(bridge as unknown as EventEmitter).emit('textChunk', 's-1', 'hello ');
(bridge as unknown as EventEmitter).emit('toolCall', {
sessionId: 's-1',
toolCallId: 'tool-1',
kind: 'shell',
status: 'running',
});
return 'agent response';
});
const ch = createChannel();
await ch.handleInbound(envelope({ messageId: 'm-1' }));
expect(ch.taskEvents.map((event) => event.type)).toEqual([
'started',
'text_chunk',
'tool_call',
'completed',
]);
expect(ch.taskEvents[1]).toMatchObject({
type: 'text_chunk',
chunk: 'hello ',
});
expect(ch.taskEvents[2]).toMatchObject({
type: 'tool_call',
toolCall: { toolCallId: 'tool-1' },
});
});
it('emits failed lifecycle event when prompt rejects', async () => {
vi.mocked(bridge.prompt).mockRejectedValue(new Error('boom'));
const ch = createChannel();
await expect(ch.handleInbound(envelope())).rejects.toThrow('boom');
expect(ch.taskEvents.map((event) => event.type)).toEqual([
'started',
'failed',
]);
expect(ch.taskEvents[1]).toMatchObject({
type: 'failed',
error: 'boom',
});
});
For cancellation coverage, add focused assertions to existing /cancel, /clear, and steer tests instead of duplicating their setup:
expect(ch.taskEvents).toContainEqual(
expect.objectContaining({ type: 'cancelled', reason: 'cancel_command' }),
);
expect(ch.taskEvents).toContainEqual(
expect.objectContaining({ type: 'cancelled', reason: 'clear' }),
);
expect(ch.taskEvents).toContainEqual(
expect.objectContaining({ type: 'cancelled', reason: 'steer' }),
);
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: fails because only started is emitted.
Add private helper:
private lifecycleBase(
chatId: string,
sessionId: string,
messageId?: string,
) {
return {
channelName: this.name,
chatId,
sessionId,
...(messageId ? { messageId } : {}),
identity: this.identity,
memoryScope: this.memoryScope,
};
}
Update started to spread this.lifecycleBase(...).
In bridgeToolCallListener, after this.onToolCall(target.chatId, event);, add:
this.emitTaskLifecycle({
type: 'tool_call',
channelName: this.name,
chatId: target.chatId,
sessionId: event.sessionId,
toolCall: event,
identity: this.identity,
memoryScope: this.memoryScope,
});
In onChunk, after this.onResponseChunk(...), add:
this.emitTaskLifecycle({
type: 'text_chunk',
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
chunk,
});
In /cancel, after active.cancelled = true;, add:
this.emitTaskLifecycle({
type: 'cancelled',
...this.lifecycleBase(active.chatId, activeSessionId, active.messageId),
reason: 'cancel_command',
});
In /clear, when active exists before waiting, add:
this.emitTaskLifecycle({
type: 'cancelled',
...this.lifecycleBase(active.chatId, id, active.messageId),
reason: 'clear',
});
In steer, after active.cancelled = true;, add:
this.emitTaskLifecycle({
type: 'cancelled',
...this.lifecycleBase(active.chatId, sessionId, active.messageId),
reason: 'steer',
});
In the prompt try block, after response delivery finishes and only when !promptState.cancelled, add:
this.emitTaskLifecycle({
type: 'completed',
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
});
Convert the try/finally into try/catch/finally:
} catch (err) {
this.emitTaskLifecycle({
type: 'failed',
...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
error: err instanceof Error ? err.message : String(err),
});
throw err;
} finally {
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts
Expected: all ChannelBase tests pass.
Files:
Modify: packages/cli/src/commands/channel/config-utils.ts
Modify: packages/cli/src/commands/channel/config-utils.test.ts
Modify: packages/channels/base/src/index.ts
Test: packages/cli/src/commands/channel/config-utils.test.ts
Step 1: Write failing config parsing test
In config-utils.test.ts, add to the full config parse test or a new test:
it('preserves channel identity and metadata-only memory scope config', async () => {
const result = await parseChannelConfig('bot', {
type: 'telegram',
token: 'tok',
identity: {
id: 'ops-agent',
displayName: 'Ops Agent',
description: 'Coordinates repository operations.',
},
memoryScope: {
namespace: 'qwen-tag:ops',
mode: 'metadata-only',
},
});
expect(result.identity).toEqual({
id: 'ops-agent',
displayName: 'Ops Agent',
description: 'Coordinates repository operations.',
});
expect(result.memoryScope).toEqual({
namespace: 'qwen-tag:ops',
mode: 'metadata-only',
});
});
Run:
cd packages/cli
npx vitest run src/commands/channel/config-utils.test.ts
Expected: fails if parser drops the new config fields.
In config-utils.ts, add to the returned config object:
identity: rawConfig['identity'] as ChannelConfig['identity'],
memoryScope: rawConfig['memoryScope'] as ChannelConfig['memoryScope'],
If types.ts exports the new types and index.ts already uses export type { ... } from './types.js';, add the new type names there:
ChannelIdentityConfig,
ChannelRuntimeIdentity,
ChannelMemoryScopeConfig,
ChannelRuntimeMemoryScope,
ChannelTaskLifecycleEvent,
Run:
cd packages/channels/base
npx vitest run src/ChannelBase.test.ts src/SessionRouter.test.ts
cd ../../cli
npx vitest run src/commands/channel/config-utils.test.ts
Expected: all focused tests pass.
Run from repo root:
npm run build
npm run typecheck
Expected: both commands pass. Existing warning-only lint output from dependency install or unrelated packages is not part of this verification.
Review the diff and stage only intended files:
git diff -- packages/channels/base/src/types.ts packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/channels/base/src/index.ts packages/cli/src/commands/channel/config-utils.ts packages/cli/src/commands/channel/config-utils.test.ts
git add packages/channels/base/src/types.ts packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/channels/base/src/index.ts packages/cli/src/commands/channel/config-utils.ts packages/cli/src/commands/channel/config-utils.test.ts
git commit -m "feat(channels): add identity and task lifecycle metadata"
Expected: commit contains no package-lock.json, generated assets, or platform adapter UI changes.